ICoded
ICoded

Reputation: 341

D3.js highlight related nodes / links

Question: I want to fade / highlight a whole dependency chain, based on the link type.

To do so I utilize the mouseEnter event, which currently store all links and nodes. Further I fade all nodes and links and only highlight those nodes which where filtered as related nodes and links. It would require to check all related nodes and links again, if those have connections from type need too. This must be done as long as dependency connections are found.. I can´t figure out a proper algorythm.

Examples:

For better understanding I created a beer ingredients dependency, which looks lika a star. For those purposes my version is fine. BUT the second chain, about car -> wheel -> tires -> rubber and the radio is giving me headache. The radio is a "use" dependency, means its not mandatory for the chain and shouldn´t be hightlighted.

Expected result:

If the cursor is over car all connected nodes with a "need" dependency should be highlighted and the rest should fade.

For those who wants to help me with, please dont hesitate to ask, if anything is unclear.

<!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>

Upvotes: 3

Views: 1235

Answers (2)

Robin Mackenzie
Robin Mackenzie

Reputation: 19289

There's a couple of issues you can look at to get the fade/ highlight running:


Firstly, notice the force method mutates the links array in graph.

E.g your first link starts like this:

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

But becomes this:

{
  "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"
}

So you need a recursive function but you will get empty arrays if you refer to link.source - instead you need to refer to link.source.id because that's how force has updated your graph object per the above example.

Here's a fairly verbose, recursive function that returns all the nodes and links for a given node id that are linked by links of a give 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)
  };
}

Note the function returns an array of index which is an attribute force assigns to each node and link. This makes the filtering for the fade/ highlight more explicit later on.


Now, in mouseEnter you can start off by calling this function to return just the nodes linked together by links of a certain type and passing d to initialise the search:

function mouseEnter(event, d) {

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

That replaces the construction of the related array which in your OP is not with respect to type. Whilst that could be easily fixed, the other issue in your current mouseEnter is that you have these lines.

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

and

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

This is causing the highlight of the link (and link label) to any linked nodes instead of just the ones linked by a type (e.g. need).

So, I propose you replace with this code block (I moved all the lines to fade everything into their own section):

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

Which is now only highlighting nodes and links per the index returned from the nodeByTypeAfterForce function above.


The working example is below where nodeByTypeAfterForce is just dropped in after the definition of graph and the only other edits are in 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>

You will run into a stack overflow if you define a link like this e.g. with "Yeast":

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

So some extra logic would be required in nodesByTypeAfterForce to accommodate self-referencing links.

Upvotes: 1

Michael Rovinsky
Michael Rovinsky

Reputation: 7230

Use recursion (getNeedChain calls itself until done):

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

Upvotes: 0

Related Questions