Search code examples
javascriptnetwork-programminggraphoverlayvis.js

Exclude edges from participating in the layout


Consider a graph like the one shown below:

Graph taken from the VisJS example site

I would like to be able to display/hide the red edges (forget that they are hand drawn) shown below when the user clicks a button or similar:

Same example with added red edges

I don't want the red edges to participate in the layout but instead for them to be shown as a kind of overlay. It would be nice if the edges could try to avoid overlapping any nodes in their path, but its definitely not required.

I think if I could set a boolean flag on the edges telling the layout engine to either include or exclude them from the layout setup, it could work. There is a physics parameter on the edge that I can override, but it doesn't seem to help - the edge still participates in the layout.

I could probably also write some scripting which tracks the nodes and draw the red edges in another graph above, but that is specifically what I want to avoid.


Solution

  • When using a hierarchical layout in vis network (options.layout.hierarchical.enabled = true) there doesn't appear to be an option which achieves this. This could however be achieved with an overlay. The question mentions that this isn't desired, but adding it as an option. An example is incorporated into the post below and also at https://jsfiddle.net/7abovhtu/.

    In summary the solution places an overlay canvas on top of the vis network canvas. Clicks on the overlay canvas are passed through to the vis network canvas due to the CSS pointer-events: none;. Extra edges are drawn onto the overlay canvas using the positioning of the nodes. Updates to the overlay canvas are triggered by the vis network event afterDrawing which triggers whenever the network changes (dragging, zooming, etc.).

    This answer makes use of the closest point to an ellipse calculation provided in the answer https://stackoverflow.com/a/18363333/1620449 to end the lines at the edge of the nodes. This answer also makes use of the function in the answer https://stackoverflow.com/a/6333775/1620449 to draw an arrow on a canvas.

    // create an array with nodes
    var nodes = new vis.DataSet([
      { id: 1, label: "Node 1" },
      { id: 2, label: "Node 2" },
      { id: 3, label: "Node 3" },
      { id: 4, label: "Node 4" },
      { id: 5, label: "Node 5" },
      { id: 6, label: "Node 6" },
      { id: 7, label: "Node 7" },
    ]);
    
    // create an array with edges
    var edges = new vis.DataSet([
      { from: 1, to: 2 },
      { from: 2, to: 3 },
      { from: 3, to: 4 },
      { from: 3, to: 5 },
      { from: 3, to: 6 },
      { from: 6, to: 7 }
    ]);
    
    // create an array with extra edges displayed on button press
    var extraEdges = [
      { from: 7, to: 5 },
      { from: 6, to: 1 }
    ];
    
    // create a network
    var container = document.getElementById("network");
    var data = {
      nodes: nodes,
      edges: edges,
    };
    var options = {
      layout: {
        hierarchical: {
          enabled: true,
          direction: 'LR',
          sortMethod: 'directed',
          shakeTowards: 'roots'
        }
      }
    };
    var network = new vis.Network(container, data, options);
    
    // Create an overlay for displaying extra edges
    var overlayCanvas = document.getElementById("overlay");
    var overlayContext = overlayCanvas.getContext("2d");
    
    // Function called to draw the extra edges, called on initial display and
    // when the network completes each draw (due to drag, zoom etc.)
    function drawExtraEdges(){
      // Resize overlay canvas in case the continer has changed
      overlayCanvas.height = container.clientHeight;
      overlayCanvas.width = container.clientWidth;
      
      // Begin drawing path on overlay canvas
      overlayContext.beginPath();
      
      // Clear any existing lines from overlay canvas
      overlayContext.clearRect(0, 0, overlayCanvas.width, overlayCanvas.height);
      
      // Loop through extra edges to draw them
        extraEdges.forEach(edge => {
        // Gather the necessary coordinates for the start and end shapres
        const startPos = network.canvasToDOM(network.getPosition(edge.from));
        const endPos = network.canvasToDOM(network.getPosition(edge.to));
        const endBox = network.getBoundingBox(edge.to);
        
        // Determine the radius of the ellipse based on the scale of network
        // Start and end ellipse are presumed to be the same size
        const scale = network.getScale();
        const radiusX = ((endBox.right * scale) - (endBox.left * scale)) / 2;
        const radiusY = ((endBox.bottom * scale) - (endBox.top * scale)) / 2;
        
        // Get the closest point on the end ellipse to the start point
        const endClosest = getEllipsePt(endPos.x, endPos.y, radiusX, radiusY, startPos.x, startPos.y);
        
        // Now we have an end point get the point on the ellipse for the start
        const startClosest = getEllipsePt(startPos.x, startPos.y, radiusX, radiusY, endClosest.x, endClosest.y);
        
        // Draw arrow on diagram
        drawArrow(overlayContext, startClosest.x, startClosest.y, endClosest.x, endClosest.y);
      });
      
      // Apply red color to overlay canvas context
      overlayContext.strokeStyle = '#ff0000';
      
      // Make the line dashed
      overlayContext.setLineDash([10, 3]);
      
      // Apply lines to overlay canvas
      overlayContext.stroke();
    }
    
    // Adjust the positioning of the lines each time the network is redrawn
    network.on("afterDrawing", function (event) {
      // Only draw the lines if they have been toggled on with the button
      if(extraEdgesShown){
        drawExtraEdges();
      }
    });
    
    // Add button event to show / hide extra edges
    var extraEdgesShown = false; 
    document.getElementById('extraEdges').onclick = function() {
      if(!extraEdgesShown){
        if(extraEdges.length > 0){
          // Call function to draw extra lines
          drawExtraEdges();
          extraEdgesShown = true;
        }
      } else {
        // Remove extra edges
        // Clear the overlay canvas
        overlayContext.clearRect(0, 0, overlayCanvas.width, overlayCanvas.height);
        extraEdgesShown = false;
      }
    }
    
    //////////////////////////////////////////////////////////////////////
    // Elllipse closest point calculation
    // https://stackoverflow.com/a/18363333/1620449
    //////////////////////////////////////////////////////////////////////
    var halfPI = Math.PI / 2;
    var steps = 8; // larger == greater accuracy
    
    // calc a point on the ellipse that is "near-ish" the target point
    // uses "brute force"
    function getEllipsePt(cx, cy, radiusX, radiusY, targetPtX, targetPtY) {
        // calculate which ellipse quadrant the targetPt is in
        var q;
        if (targetPtX > cx) {
            q = (targetPtY > cy) ? 0 : 3;
        } else {
            q = (targetPtY > cy) ? 1 : 2;
        }
    
        // calc beginning and ending radian angles to check
        var r1 = q * halfPI;
        var r2 = (q + 1) * halfPI;
        var dr = halfPI / steps;
        var minLengthSquared = 200000000;
        var minX, minY;
    
        // walk the ellipse quadrant and find a near-point
        for (var r = r1; r < r2; r += dr) {
    
            // get a point on the ellipse at radian angle == r
            var ellipseX = cx + radiusX * Math.cos(r);
            var ellipseY = cy + radiusY * Math.sin(r);
    
            // calc distance from ellipsePt to targetPt
            var dx = targetPtX - ellipseX;
            var dy = targetPtY - ellipseY;
            var lengthSquared = dx * dx + dy * dy;
    
            // if new length is shortest, save this ellipse point
            if (lengthSquared < minLengthSquared) {
                minX = ellipseX;
                minY = ellipseY;
                minLengthSquared = lengthSquared;
            }
        }
    
        return ({
            x: minX,
            y: minY
        });
    }
    
    //////////////////////////////////////////////////////////////////////
    // Draw Arrow on Canvas Function
    // https://stackoverflow.com/a/6333775/1620449
    //////////////////////////////////////////////////////////////////////
    function drawArrow(ctx, fromX, fromY, toX, toY) {
      var headLength = 10; // length of head in pixels
      var dX = toX - fromX;
      var dY = toY - fromY;
      var angle = Math.atan2(dY, dX);
      ctx.fillStyle = "red";
      ctx.moveTo(fromX, fromY);
      ctx.lineTo(toX, toY);
      ctx.lineTo(toX - headLength * Math.cos(angle - Math.PI / 6), toY - headLength * Math.sin(angle - Math.PI / 6));
      ctx.moveTo(toX, toY);
      ctx.lineTo(toX - headLength * Math.cos(angle + Math.PI / 6), toY - headLength * Math.sin(angle + Math.PI / 6));
    }
    #container {
      width: 100%;
      height: 80vh;
      border: 1px solid lightgray;
      position: relative;
    }
    
    #network, #overlay {
      width: 100%;
      height: 100%;
      position: absolute;
      top: 0;
      left: 0;
    }
    
    #overlay {
      z-index: 100;
      pointer-events: none;
    }
    <script src="https://visjs.github.io/vis-network/standalone/umd/vis-network.min.js"></script>
    <button id="extraEdges">Toggle Extra Edges</button>
    <div id="container">
      <div id="network"></div>
      <canvas width="600" height="400" id="overlay"></canvas>
    </div>