Search code examples
javascriptd3.jsresetd3-force-directed

Why reset filter is not working in d3 js force directed graph?


I am trying to re draw the force directed graph and bring it back to its original state when the user clicks "Reset Filter" button.

But it is not working as expected. Please refer the jsfiddle below.

JSFiddle Link : Working Fiddle

var filter = document.querySelector('#filter');
filter.addEventListener('change', function(event) {
d3.select("svg").remove();
  svg = d3.select("body").append("svg").attr("width","960").attr("height", "600");
  filterData(event.target.value);
})

 var resetFilter = document.querySelector('#reset');
resetFilter.addEventListener('click', function(event) {
  d3.select("svg").remove();
  svg = d3.select("body").append("svg").attr("width","960").attr("height", "600");
  graph = Object.assign({}, store);
  drawGraph(graph);
}) 

function filterData(id) {
    g.html('');
    graph = Object.assign({}, store);
    graph.nodes = [];
    graph.links = [];
    dummyStore = [id];
    store.links.forEach(function(link) {
            if(link.source.id === id) {
                graph.links.push(link);
                dummyStore.push(link.target.id);
            } else if (link.target.id === id) {
                graph.links.push(link);
                dummyStore.push(link.source.id)
            }
        });
        store.nodes.forEach(function(node) {
            if (dummyStore.includes(node.id)) {
                graph.nodes.push(node);
            }
        })
    drawGraph();
    }

Can someone please let me know what is missing here ?


Solution

  • Currently you are recreating a simulation every time and you are also recreating the visualization each time: rather than doing an enter/update/exit cycle with nodes coming and going, you wipe the whole slate clean, removing everything from the SVG.

    Now, we could add an enter/update/exit cycle as filtering occurs, but if we only need to hide the links and nodes that have been filtered, we can just hide them instead of removing them. I clarified that this approach could be satisfactory in the comments because it makes the task much easier.

    We can set opacity to 0 and pointer events to none for nodes and links that have been filtered out, and reset these values to 1 and all for the links and nodes that need to be shown.

    Using your code as much as possible, we could have something like:

    // Re-apply the filter each time the input changes:    
    d3.select("input").on("keyup", function() {
      // We want to know if we have a value of ""
      var value = this.value.length ? this.value : undefined;
    
      nodeElements.each(function(d) {
        d.show = false; // by default don't show if a filter is applied.
      })
    
      // Go through each datum (d.target and d.source) and
      // set a flag to show a node if it is connected (or is) included in the filter
      // also show the link or hide it as needed: 
      linkElements.each(function(d) {
        if(value && d.source.id.indexOf(value) == -1 && d.target.id.indexOf(value) == -1) {
          d3.select(this).attr("opacity",0);
        }
        else {
          d.source.show = d.target.show = true;
          d3.select(this).attr("opacity",1);
        }
      })
    
      // Now just hide/show text and circles as appropriate.
      nodeElements.attr("opacity",function(d) { return d.show ? 1 : 0 });
      textElements.attr("opacity",function(d) { return d.show ? 1 : 0 });
    
    })
    

    I didn't set pointer-events here for the sake of brevity, it would be simpler to use a class to set both opacity and pointer events simultaneously. The filter is also caps-sensitive.

    As the datum for each pair of node and text is the same (and referenced in the link datums), we don't need to update the datum for each separately.

    The hidden nodes continue to have forces acting one them: they continue to be positioned in the background every tick. If we removed the SVG elements, but didn't redefine the simulation, the simulation would still calculate their position each tick as well. If we want neither of these things, then we need a fairly different approach.

    Here's a small example in snippet form:

    var graph = {
    'nodes':[
    {'id':'Menu','group':0},
    {'id':'Item1','group':1},
    {'id':'Item2','group':1},
    {'id':'Item3','group':1},
    {'id':'Item4','group':1},
    {'id':'Item5','group':1},
    {'id':'SubItem1_Item1','group':2},
    {'id':'SubItem2_Item1','group':2}],
    'links':[
    
    {'source':'Menu','target':'Item1','value':1,'type':'A'},
    {'source':'Menu','target':'Item2','value':8,'type':'A'},
    {'source':'Menu','target':'Item3','value':10,'type':'A'},
    {'source':'Menu','target':'Item3','value':1,'type':'A'},
    {'source':'Menu','target':'Item4','value':1,'type':'A'},
    {'source':'Menu','target':'Item5','value':1,'type':'A'},
    
    /* Item1 is linked to SubItems */
    {'source':'Item1','target':'SubItem1_Item1','value':2,'type':'A'},
    {'source':'Item1','target':'SubItem2_Item1','value':1,'type':'A'},
    
    /* Interconnected Items */
    {'source':'Item5','target':'Item4','value':2,'type':'A'},
    {'source':'Item2','target':'Item3','value':1,'type':'A'},
    ]};
    
    
    var width = 500;
    var height= 300;
    var color = d3.scaleOrdinal(d3.schemeCategory10);
    
    var svg = d3.select("body").append("svg")
      .attr("width",width)
      .attr("height",height);
      
    var grads = svg.append("defs").selectAll("radialGradient")
        .data(graph.nodes)
        .enter()
        .append("radialGradient")
        .attr("gradientUnits", "objectBoundingBox")
        .attr("cx", 0)
        .attr("fill", function(d) { return color(d.id); })
        .attr("cy", 0)
        .attr("r", "100%")
        .attr("id", function(d, i) { return "grad" + i; });
     
       grads.append("stop")
        .attr("offset", "0%")
        .style("stop-color", "white");
     
       grads.append("stop")
        .attr("offset", "100%")
        .style("stop-color",  function(d) { return color(d.id); });    
    
    
    
    var simulation = d3.forceSimulation()
      .force("link", d3.forceLink().distance(200).id(function(d) {
        return d.id;
      }))
      .force("charge", d3.forceManyBody().strength(-1000))
      .force("center", d3.forceCenter(width / 2, height / 2));
      
    var g = svg.append("g")
      .attr("class", "everything");
      
    var linkElements = g.append("g")
      .attr("class", "links")
      .selectAll("line")
      .data(graph.links)
      .enter().append("line")
      .style("stroke-width",5.5)
      .style("stroke", "grey")
      
    
    var nodeElements =  g.append("g")
      .attr("class", "nodes")
      .selectAll("circle")
      .data(graph.nodes)
      .enter().append("circle")
      .attr("r", 60)
      .attr("stroke", "#fff")
      .attr('stroke-width', 21)
      .attr("id", function(d) { return d.id })
         .attr('fill', function(d, i) { return 'url(#grad' + i + ')'; })
         .on('contextmenu', function(d){ 
            d3.event.preventDefault();
            menu(d3.mouse(svg.node())[0], d3.mouse(svg.node())[1]);
        })
          .on('mouseover', selectNode)
          .on('mouseout', releaseNode)
      .call(d3.drag()
        .on("start", dragstarted)
        .on("drag", dragged)
        .on("end", dragended));
          
    var textElements = g.append("g")    // use g.append instead of svg.append to enable zoom
      .attr("class", "texts")
      .selectAll("text")
      .data(graph.nodes)
      .enter().append("text")
        .attr("text-anchor", "end")
      .text(function(node) {
        return node.id
      })
      .attr("font-size", 55)
      .attr("font-family", "sans-serif")
      .attr("fill", "black")
      .attr("style", "font-weight:bold;")
      .attr("dx", 30)
      .attr("dy", 80)
    
    
      
    function ticked() {
      linkElements
        .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; });
      nodeElements
        .attr("cx", function(d) { return d.x; })
        .attr("cy", function(d) { return d.y; })
        .each(d => { d3.select('#t_' + d.id).attr('x', d.x + 10).attr('y', d.y + 3); });
        textElements
        .attr('x', function(d) {
          return d.x
        })
        .attr('y', function(d) {
          return d.y
        });
    }
    
    simulation
      .nodes(graph.nodes)
      .on("tick", ticked);
    
    simulation.force("link")
      .links(graph.links);
    
    
    function zoom_actions() {
      g.attr("transform", d3.event.transform)
    }
    
    function dragstarted(d) {
      if (!d3.event.active) simulation.alphaTarget(0.3).restart();
      d.fx = d.x;
      d.fy = d.y;
    }
    
    function dragged(d) {
      d.fx = d3.event.x;
      d.fy = d3.event.y;
    }
    
    function dragended(d) {
      if (!d3.event.active) simulation.alphaTarget(0);
      d.fx = null;
      d.fy = null;
    }
    
    function selectNode(selectedNode) {
      var neighbors = getNeighbors(selectedNode)
    
      nodeElements
        .attr('fill', function(node) {
         	return getNodeColor(node,neighbors,selectedNode);
       })
      .transition().duration(500)
      .attr('r', function(node) {
         	return getNodeRadius(node,neighbors);
       });
         
       textElements.transition().duration(500).style('font-size', function(node) {
        return getTextColor(node, neighbors)
      })
    }
    
    function releaseNode() {
    nodeElements.transition().duration(500)
       .attr('r', 60);
    nodeElements.attr('fill', function(d, i) { return 'url(#grad' + i + ')'; })
    
       linkElements.style('stroke', 'grey');
    }
    
    function getNeighbors(node) {
      return graph.links.reduce(function(neighbors, link) {
        if (link.target.id === node.id) {
          neighbors.push(link.source.id)
        } else if (link.source.id === node.id) {
          neighbors.push(link.target.id)
        }
        return neighbors
      }, [node.id])
    }
    
    function getNodeColor(node, neighbors, selectedNode) {
      // If is neighbor
      if (Array.isArray(neighbors) && neighbors.indexOf(node.id) > -1) {
        return 'url(#grad' + selectedNode.index + ')'
        // return node.level === 1 ? '#9C4A9C' : 'rgba(251, 130, 30, 1)'
      }  else {
          return 'url(#grad' + node.index + ')'
      }
      //return node.level === 0 ? '#91007B' : '#D8ABD8'
    }
    
    function getNodeRadius(node, neighbors) {
      // If is neighbor
      if ( neighbors.indexOf(node.id) > -1) {
        return '100'
      } 
      else {
            return '60'
      }
    }
    
    function getTextColor(node, neighbors) {
      return Array.isArray(neighbors) && neighbors.indexOf(node.id) > -1 ? '40px' : '25px'
    }
    
    d3.select("input").on("keyup", function() {
      var value = this.value.length ? this.value : undefined;
      
      nodeElements.each(function(d) {
        d.show = false; // by default don't show if a filter is applied.
      })
      
      linkElements.each(function(d) {
        if(value && d.source.id.indexOf(value) == -1 && d.target.id.indexOf(value) == -1) {
          d3.select(this).attr("opacity",0);
        }
        else {
          d.source.show = d.target.show = true;
          d3.select(this).attr("opacity",1);
        }
      })
      
      nodeElements.attr("opacity",function(d) { return d.show ? 1 : 0 });
      textElements.attr("opacity",function(d) { return d.show ? 1 : 0 });
    
    })
    
    d3.select("button").on("click", function() {
      d3.select("input").property("value","");
      g.selectAll("*").attr("opacity",1);
    })
    <script src="https://d3js.org/d3.v4.min.js"></script>
    Filter: <input type="text" name="filter" id="filter"/>
    <button id = 'reset'>Reset Filter</button><br />

    And here's a fiddle.