D3.js 突出显示相关节点/链接

D3.js highlight related nodes / links

问题:我想根据link类型淡出/高亮整个依赖链。

为此,我使用了 mouseEnter 事件,该事件当前存储所有 link 和节点。此外,我淡化所有节点和 links,只突出显示那些被过滤为相关节点和 links 的节点。它将需要再次检查所有相关节点和 links,如果它们也有来自类型 need 的连接。只要找到依赖关系就必须这样做。我想不出合适的算法。

示例:

为了更好地理解,我创建了一个啤酒成分依赖项,它看起来像一个星星。出于这些目的,我的版本很好。 但是 第二条链,关于汽车 -> 车轮 -> 轮胎 -> 橡胶和收音机让我头疼。 radio 是一个“use”依赖项,意味着它对链不是强制性的,不应该被突出显示。

预期结果:

如果光标悬停 car 所有具有“需要”依赖关系的连接节点都应突出显示,其余节点应淡化。

有什么不明白的,欢迎大家多多指教。

<!DOCTYPE html>
<html>

<head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <!-- d3.js framework -->
    <script src="https://d3js.org/d3.v6.js"></script>
    <!-- fontawesome stylesheet https://fontawesome.com/ -->
    <script src="https://kit.fontawesome.com/39094309d6.js" crossorigin="anonymous"></script>
</head>

<style>
    body {
        height: 100%;
        background: #e6e7ee;
        overflow: hidden;
        margin: 0px;
    }

    .faded {
        opacity: 0.1;
        transition: 0.3s opacity;
    }

    .highlight {
        opacity: 1;
    }
</style>

<body>
    <svg id="svg"></svg>

    <script>
        var graph = {
            "nodes": [
                {
                    "id": 0,
                    "name": "beer",
                },
                {
                    "id": 1,
                    "name": "water",
                },
                {
                    "id": 2,
                    "name": "hop",
                },
                {
                    "id": 3,
                    "name": "malt",
                },
                {
                    "id": 4,
                    "name": "yeast",
                },
                {
                    "id": 10,
                    "name": "car",
                },
                {
                    "id": 11,
                    "name": "wheels",
                },
                {
                    "id": 12,
                    "name": "tires",
                },
                {
                    "id": 13,
                    "name": "rubber",
                },
                {
                    "id": 14,
                    "name": "radio",
                }
            ],
            "links": [
                {
                    "source": 0,
                    "target": 1,
                    "type": "need"
                },
                {
                    "source": 0,
                    "target": 2,
                    "type": "need"
                },
                {
                    "source": 0,
                    "target": 3,
                    "type": "need"
                },
                {
                    "source": 0,
                    "target": 4,
                    "type": "need"
                },
                {
                    "source": 10,
                    "target": 11,
                    "type": "need"
                },
                {
                    "source": 11,
                    "target": 12,
                    "type": "need"
                },
                {
                    "source": 12,
                    "target": 13,
                    "type": "need"
                },
                {
                    "source": 10,
                    "target": 14,
                    "type": "use"
                }

            ]
        }

        var svg = d3.select("svg")
            .attr("class", "canvas")
            .attr("width", window.innerWidth)
            .attr("height", window.innerHeight)
            .call(d3.zoom().on("zoom", function (event) {
                svg.attr("transform", event.transform)
            }))
            .append("g")

        // append markers to svg
        svg.append("defs").append("marker")
            .attr("id", "arrowhead")
            .attr("viewBox", "-0 -5 10 10")
            .attr("refX", 8)
            .attr("refY", 0)
            .attr("orient", "auto")
            .attr("markerWidth", 50)
            .attr("markerHeight", 50)
            .attr("xoverflow", "visible")
            .append("svg:path")
            .attr("d", "M 0,-1 L 2 ,0 L 0,1")
            .attr("fill", "black")
            .style("stroke", "none")

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

        var force = d3.forceSimulation()
            .force("link", d3.forceLink().id(function (d) {
                return d.id
            }).distance(80))
            .force("charge", d3.forceManyBody().strength(-100))
            .force("center", d3.forceCenter(window.innerWidth / 2, window.innerHeight / 2))
            .force("collision", d3.forceCollide().radius(90))

        initialize()

        function initialize() {

            link = linksContainer.selectAll(".link")
                .data(graph.links)
                .join("line")
                .attr("class", "link")
                .attr('marker-end', 'url(#arrowhead)')
                .style("display", "block")
                .style("stroke", "black")
                .style("stroke-width", 1)

            linkPaths = linksContainer.selectAll(".linkPath")
                .data(graph.links)
                .join("path")
                .style("pointer-events", "none")
                .attr("class", "linkPath")
                .attr("fill-opacity", 1)
                .attr("stroke-opacity", 1)
                .attr("id", function (d, i) { return "linkPath" + i })
                .style("display", "block")

            linkLabels = linksContainer.selectAll(".linkLabel")
                .data(graph.links)
                .join("text")
                .style("pointer-events", "none")
                .attr("class", "linkLabel")
                .attr("id", function (d, i) { return "linkLabel" + i })
                .attr("font-size", 16)
                .attr("fill", "black")
                .text("")

            linkLabels
                .append("textPath")
                .attr('xlink:href', function (d, i) { return '#linkPath' + i })
                .style("text-anchor", "middle")
                .style("pointer-events", "none")
                .attr("startOffset", "50%")
                .text(function (d) { return d.type })

            node = nodesContainer.selectAll(".node")
                .data(graph.nodes, d => d.id)
                .join("g")
                .attr("class", "node")
                .call(d3.drag()
                    .on("start", dragStarted)
                    .on("drag", dragged)
                    .on("end", dragEnded)
                )

            node.selectAll("circle")
                .data(d => [d])
                .join("circle")
                .attr("r", 30)
                .style("fill", "whitesmoke")
                .on("mouseenter", mouseEnter)
                .on("mouseleave", mouseLeave)

            node.selectAll("text")
                .data(d => [d])
                .join("text")
                .style("class", "icon")
                .attr("font-family", "FontAwesome")
                .attr("dominant-baseline", "central")
                .attr("text-anchor", "middle")
                .attr("font-size", 20)
                .attr("fill", "black")
                .attr("pointer-events", "none")
                .attr("dy", "-1em")
                .text(function (d) {
                    return d.name
                })
            node.append("text")
                .attr("dominant-baseline", "central")
                .attr("text-anchor", "middle")
                .attr("font-size", 13)
                .attr("fill", "black")
                .attr("pointer-events", "none")
                .attr("dy", "0.5em")
                .text(function (d) {
                    return d.id
                })

            force
                .nodes(graph.nodes)
                .on("tick", ticked);

            force
                .force("link")
                .links(graph.links)
        }

        function mouseEnter(event, d) {
            const selNodes = node.selectAll("circle")
            const selLink = link
            const selLinkLabel = linkLabels
            const selText = node.selectAll("text")
            const related = []
            const relatedLinks = []

            related.push(d)
            force.force('link').links().forEach((link) => {
                if (link.source === d || link.target === d) {
                    relatedLinks.push(link)
                    if (related.indexOf(link.source) === -1) { related.push(link.source) }
                    if (related.indexOf(link.target) === -1) { related.push(link.target) }
                }
            })
            selNodes.classed('faded', true)
            selNodes.filter((dNodes) => related.indexOf(dNodes) > -1)
                .classed('highlight', true)
            selLink.classed('faded', true)
            selLink.filter((dLink) => dLink.source === d || dLink.target === d)
                .classed('highlight', true)
            selLinkLabel.classed('faded', true)
            selLinkLabel.filter((dLinkLabel) => dLinkLabel.source === d || dLinkLabel.target === d)
                .classed('highlight', true)
            selText.classed('faded', true)
            selText.filter((dText) => related.indexOf(dText) > -1)
                .classed('highlight', true)
            
            force.alphaTarget(0.0001).restart()
        }

        function mouseLeave(event, d) {
            const selNodes = node.selectAll("circle")
            const selLink = link
            const selLinkLabel = linkLabels
            const selText = node.selectAll("text")

            selNodes.classed('faded', false)
            selNodes.classed('highlight', false)
            selLink.classed('faded', false)
            selLink.classed('highlight', false)
            selLinkLabel.classed('faded', false)
            selLinkLabel.classed('highlight', false)
            selText.classed('faded', false)
            selText.classed('highlight', false)
            
            force.restart()
        }

        function ticked() {
            // update link positions
            link
                .attr("x1", function (d) {
                    return d.source.x;
                })
                .attr("y1", function (d) {
                    return d.source.y;
                })
                .attr("x2", function (d) {
                    return d.target.x;
                })
                .attr("y2", function (d) {
                    return d.target.y;
                });

            // update node positions
            node
                .attr("transform", function (d) {
                    return "translate(" + d.x + ", " + d.y + ")";
                });

            linkPaths.attr('d', function (d) {
                return 'M ' + d.source.x + ' ' + d.source.y + ' L ' + d.target.x + ' ' + d.target.y;
            });

            linkLabels.attr('transform', function (d) {
                if (d.target.x < d.source.x) {
                    var bbox = this.getBBox();

                    rx = bbox.x + bbox.width / 2;
                    ry = bbox.y + bbox.height / 2;
                    return 'rotate(180 ' + rx + ' ' + ry + ')';
                }
                else {
                    return 'rotate(0)';
                }
            });

        }

        function dragStarted(event, d) {
            if (!event.active) force.alphaTarget(0.3).restart();
            d.fx = d.x;
            d.fy = d.y;

            PosX = d.x
            PosY = d.y
        }

        function dragged(event, d) {
            d.fx = event.x;
            d.fy = event.y;
        }

        function dragEnded(event, d) {
            if (!event.active) force.alphaTarget(0);
            d.fx = undefined;
            d.fy = undefined;
        }

    </script>
</body>

</html>

使用递归(getNeedChain 调用自身直到完成):

const getNeedChain = id => {
  const links = graph.links.filter(l => l.source === id && l.type === "need");
  const nodes = links.map(l => graph.nodes.find(n => n.id === l.target));
  const linked = nodes.reduce((c, n) => [...c, ...getNeedChain(n.id)], []);
  return [...nodes, ...linked];
}

console.log('Beer needs: ', getNeedChain(0));  // Returns 4 nodes
console.log('Car needs: ', getNeedChain(10));  // Returns 3 nodes

您可以查看几个问题来获得淡入淡出/高光 运行宁:


首先,注意 force 方法改变了 graph 中的 links 数组。

例如,您的第一个 link 是这样开始的:

{
  "source": 0,
  "target": 1,
  "type": "need"
}

却变成了这样:

{
  "index": 0
  "source": {
    "id": 0
    "index": 0
    "name": "beer"
    "vx": 0.036971029563580046
    "vy": 0.04369386654517388
    "x": 394.1514674087123
    "y": 220.18458726626062
  },
  "target": {
    "id": 1
    "index": 1
    "name": "water"
    "vx": -0.021212609689083086
    "vy": 0.01105162589441528
    "x": 568.911363724937
    "y": 177.07991527420614
  },
  "type": "need"
}

所以你需要一个递归函数,但是如果你引用 link.source 你会得到空数组 - 而你需要引用 link.source.id 因为这就是 force 更新你的图表的方式上述示例中的对象。

这是一个相当冗长的递归函数,它 returns 所有节点和 links 给定节点 id linked 由 links给 type:

function nodesByTypeAfterForce(nodeId, sieved, type) {

  // get the links for the node per the type
  const newLinks = graph.links
    .filter(link => link.type === type && link.source.id === nodeId);

  // get the linked nodes to nodeId from the links 
  const newNodes = newLinks
    .map(link => graph.nodes.find(newNode => newNode.id === link.target.id));

  // concatenate new nodes and links
  (sieved.links = sieved.links || []).push(...newLinks);
  (sieved.nodes = sieved.nodes || []).push(...newNodes);

  // recursively visit linked nodes until exhausted options
  newNodes.forEach(node => nodesByTypeAfterForce(node.id, sieved, type));

  // return indices relevant nodes and links
  return {
    nodes: sieved.nodes.map(node => node.index),
    links: sieved.links.map(link => link.index)
  };
}

请注意函数 return 是一个 index 的数组,它是一个属性 force 分配给每个节点和 link。这使得以后对淡入淡出/高光的过滤更加明确。


现在,在 mouseEnter 中,您可以通过将此函数调用到 return 开始,只是节点 link 由某个 [=25] 的 link 聚合在一起=] 并传递 d 以初始化搜索:

function mouseEnter(event, d) {

  // sub graph for the hovered node
  const sieved = nodesByTypeAfterForce(d.id, {nodes: [d]}, "need");
  //...
}

它取代了 related 数组的构造,在您的 OP 中,它与 type 无关。虽然这很容易解决,但您当前 mouseEnter 中的另一个问题是您有这些行。

selLink
  .filter((dLink) => dLink.source === d || dLink.target === d)

selLinkLabel
  .filter((dLinkLabel) => dLinkLabel.source === d || dLinkLabel.target === d)
  .classed('highlight', true)

这导致 link(和 link 标签)突出显示到任何 linked 节点,而不仅仅是 linked 由 type(例如 need)。

所以,我建议你用这个代码块替换(我移动了所有行以将所有内容淡化到它们自己的部分):

// only highlight from sieved
node.selectAll("circle")
  .filter(node => sieved.nodes.indexOf(node.index) > -1)
  .classed('highlight', true)
link
  .filter(link => sieved.links.indexOf(link.index) > -1)
  .classed('highlight', true)
linkLabels
  .filter(link => sieved.links.indexOf(link.index) > -1)
  .classed('highlight', true)
node.selectAll("text")
  .filter(node => sieved.nodes.indexOf(node.index) > -1)
  .classed('highlight', true)

现在仅根据 index return 上面 nodeByTypeAfterForce 函数中的 link 突出显示节点和 links。


工作示例如下,其中 nodeByTypeAfterForce 只是在 graph 的定义之后放入,唯一的其他编辑在 mouseEnter:

<!DOCTYPE html>
<html>

<head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <!-- d3.js framework -->
    <script src="https://d3js.org/d3.v6.js"></script>
    <!-- fontawesome stylesheet https://fontawesome.com/ -->
    <script src="https://kit.fontawesome.com/39094309d6.js" crossorigin="anonymous"></script>
</head>

<style>
    body {
        height: 100%;
        background: #e6e7ee;
        overflow: hidden;
        margin: 0px;
    }

    .faded {
        opacity: 0.1;
        transition: 0.3s opacity;
    }

    .highlight {
        opacity: 1;
    }
</style>

<body>
    <svg id="svg"></svg>

    <script>
        var graph = {
            "nodes": [
                {
                    "id": 0,
                    "name": "beer",
                },
                {
                    "id": 1,
                    "name": "water",
                },
                {
                    "id": 2,
                    "name": "hop",
                },
                {
                    "id": 3,
                    "name": "malt",
                },
                {
                    "id": 4,
                    "name": "yeast",
                },
                {
                    "id": 10,
                    "name": "car",
                },
                {
                    "id": 11,
                    "name": "wheels",
                },
                {
                    "id": 12,
                    "name": "tires",
                },
                {
                    "id": 13,
                    "name": "rubber",
                },
                {
                    "id": 14,
                    "name": "radio",
                }
            ],
            "links": [
                {
                    "source": 0,
                    "target": 1,
                    "type": "need"
                },
                {
                    "source": 0,
                    "target": 2,
                    "type": "need"
                },
                {
                    "source": 0,
                    "target": 3,
                    "type": "need"
                },
                {
                    "source": 0,
                    "target": 4,
                    "type": "need"
                },
                {
                    "source": 10,
                    "target": 11,
                    "type": "need"
                },
                {
                    "source": 11,
                    "target": 12,
                    "type": "need"
                },
                {
                    "source": 12,
                    "target": 13,
                    "type": "need"
                },
                {
                    "source": 10,
                    "target": 14,
                    "type": "use"
                }

            ]
        }


        function nodesByTypeAfterForce(nodeId, sieved, type) {

          // get the links for the node per the type
          const newLinks = graph.links
            .filter(link => link.type === type && link.source.id === nodeId);

          // get the linked nodes to nodeId from the links 
          const newNodes = newLinks
            .map(link => graph.nodes.find(newNode => newNode.id === link.target.id));

          // concatenate new nodes and links
          (sieved.links = sieved.links || []).push(...newLinks);
          (sieved.nodes = sieved.nodes || []).push(...newNodes);

          // recursively visit linked nodes until exhausted options
          newNodes.forEach(node => nodesByTypeAfterForce(node.id, sieved, type));

          // return indices relevant nodes and links
          return {
            nodes: sieved.nodes.map(node => node.index),
            links: sieved.links.map(link => link.index)
          };
        }

        var svg = d3.select("svg")
            .attr("class", "canvas")
            .attr("width", window.innerWidth)
            .attr("height", window.innerHeight)
            .call(d3.zoom().on("zoom", function (event) {
                svg.attr("transform", event.transform)
            }))
            .append("g")

        // append markers to svg
        svg.append("defs").append("marker")
            .attr("id", "arrowhead")
            .attr("viewBox", "-0 -5 10 10")
            .attr("refX", 8)
            .attr("refY", 0)
            .attr("orient", "auto")
            .attr("markerWidth", 50)
            .attr("markerHeight", 50)
            .attr("xoverflow", "visible")
            .append("svg:path")
            .attr("d", "M 0,-1 L 2 ,0 L 0,1")
            .attr("fill", "black")
            .style("stroke", "none")

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

        var force = d3.forceSimulation()
            .force("link", d3.forceLink().id(function (d) {
                return d.id
            }).distance(80))
            .force("charge", d3.forceManyBody().strength(-100))
            .force("center", d3.forceCenter(window.innerWidth / 2, window.innerHeight / 2))
            .force("collision", d3.forceCollide().radius(90))

        initialize()

        function initialize() {

            link = linksContainer.selectAll(".link")
                .data(graph.links)
                .join("line")
                .attr("class", "link")
                .attr('marker-end', 'url(#arrowhead)')
                .style("display", "block")
                .style("stroke", "black")
                .style("stroke-width", 1)

            linkPaths = linksContainer.selectAll(".linkPath")
                .data(graph.links)
                .join("path")
                .style("pointer-events", "none")
                .attr("class", "linkPath")
                .attr("fill-opacity", 1)
                .attr("stroke-opacity", 1)
                .attr("id", function (d, i) { return "linkPath" + i })
                .style("display", "block")

            linkLabels = linksContainer.selectAll(".linkLabel")
                .data(graph.links)
                .join("text")
                .style("pointer-events", "none")
                .attr("class", "linkLabel")
                .attr("id", function (d, i) { return "linkLabel" + i })
                .attr("font-size", 16)
                .attr("fill", "black")
                .text("")

            linkLabels
                .append("textPath")
                .attr('xlink:href', function (d, i) { return '#linkPath' + i })
                .style("text-anchor", "middle")
                .style("pointer-events", "none")
                .attr("startOffset", "50%")
                .text(function (d) { return d.type })

            node = nodesContainer.selectAll(".node")
                .data(graph.nodes, d => d.id)
                .join("g")
                .attr("class", "node")
                .call(d3.drag()
                    .on("start", dragStarted)
                    .on("drag", dragged)
                    .on("end", dragEnded)
                )

            node.selectAll("circle")
                .data(d => [d])
                .join("circle")
                .attr("r", 30)
                .style("fill", "whitesmoke")
                .on("mouseenter", mouseEnter)
                .on("mouseleave", mouseLeave)

            node.selectAll("text")
                .data(d => [d])
                .join("text")
                .style("class", "icon")
                .attr("font-family", "FontAwesome")
                .attr("dominant-baseline", "central")
                .attr("text-anchor", "middle")
                .attr("font-size", 20)
                .attr("fill", "black")
                .attr("pointer-events", "none")
                .attr("dy", "-1em")
                .text(function (d) {
                    return d.name
                })
            node.append("text")
                .attr("dominant-baseline", "central")
                .attr("text-anchor", "middle")
                .attr("font-size", 13)
                .attr("fill", "black")
                .attr("pointer-events", "none")
                .attr("dy", "0.5em")
                .text(function (d) {
                    return d.id
                })

            force
                .nodes(graph.nodes)
                .on("tick", ticked);

            force
                .force("link")
                .links(graph.links)
        }

        function mouseEnter(event, d) {

            // sub graph for the hovered node
            const sieved = nodesByTypeAfterForce(d.id, {nodes: [d]}, "need");
            
            // fade everything
            node.selectAll("circle").classed('faded', true)
            node.selectAll("circle").classed('highlight', false)
            link.classed('faded', true)
            link.classed('highlight', false)
            linkLabels.classed('faded', true)
            linkLabels.classed('highlight', false)
            node.selectAll("text").classed('faded', true)
            node.selectAll("text").classed('highlight', false)
            
            // only highlight from sieved
            node.selectAll("circle")
              .filter(node => sieved.nodes.indexOf(node.index) > -1)
              .classed('highlight', true)
            link
              .filter(link => sieved.links.indexOf(link.index) > -1)
              .classed('highlight', true)
            linkLabels
              .filter(link => sieved.links.indexOf(link.index) > -1)
              .classed('highlight', true)
            node.selectAll("text")
              .filter(node => sieved.nodes.indexOf(node.index) > -1)
              .classed('highlight', true)
            
            force.alphaTarget(0.0001).restart()
        }

        function mouseLeave(event, d) {
            const selNodes = node.selectAll("circle")
            const selLink = link
            const selLinkLabel = linkLabels
            const selText = node.selectAll("text")

            selNodes.classed('faded', false)
            selNodes.classed('highlight', false)
            selLink.classed('faded', false)
            selLink.classed('highlight', false)
            selLinkLabel.classed('faded', false)
            selLinkLabel.classed('highlight', false)
            selText.classed('faded', false)
            selText.classed('highlight', false)
            
            force.restart()
        }

        function ticked() {
            // update link positions
            link
                .attr("x1", function (d) {
                    return d.source.x;
                })
                .attr("y1", function (d) {
                    return d.source.y;
                })
                .attr("x2", function (d) {
                    return d.target.x;
                })
                .attr("y2", function (d) {
                    return d.target.y;
                });

            // update node positions
            node
                .attr("transform", function (d) {
                    return "translate(" + d.x + ", " + d.y + ")";
                });

            linkPaths.attr('d', function (d) {
                return 'M ' + d.source.x + ' ' + d.source.y + ' L ' + d.target.x + ' ' + d.target.y;
            });

            linkLabels.attr('transform', function (d) {
                if (d.target.x < d.source.x) {
                    var bbox = this.getBBox();

                    rx = bbox.x + bbox.width / 2;
                    ry = bbox.y + bbox.height / 2;
                    return 'rotate(180 ' + rx + ' ' + ry + ')';
                }
                else {
                    return 'rotate(0)';
                }
            });

        }

        function dragStarted(event, d) {
            if (!event.active) force.alphaTarget(0.3).restart();
            d.fx = d.x;
            d.fy = d.y;

            PosX = d.x
            PosY = d.y
        }

        function dragged(event, d) {
            d.fx = event.x;
            d.fy = event.y;
        }

        function dragEnded(event, d) {
            if (!event.active) force.alphaTarget(0);
            d.fx = undefined;
            d.fy = undefined;
        }

    </script>
</body>

</html>

如果你这样定义 link,你将 运行 进入堆栈溢出,例如用“酵母”:

"links": [ // Yeast need yeast ??
  {
    "source": 4,
    "target": 4,
    "type": "need"
  },
  ...
]

因此 nodesByTypeAfterForce 中需要一些额外的逻辑来适应自引用 links。