1

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.

Chau
  • 5,540
  • 9
  • 65
  • 95
  • What does "participate in the layout" mean? Are you using a layout engine ( graphviz or similar ) Send the node and the blue links to the layout engine. Extract the resulting node positions. Draw the nodes. Draw the blue links. Draw ( or not ) the red links. – ravenspoint Dec 17 '21 at 12:59

2 Answers2

1

This can be achieved using either the physics or hidden options on the extra edges (those in red). For reference, these options are described in more detail at https://visjs.github.io/vis-network/docs/network/edges.html.

Please note the below options do not work when hierarchical layout is used as set in the Vis Network options options.layout.hierarchical.enabled = true.

Physics - An example of using the physics option is https://jsfiddle.net/6oac73p0. However as you mentioned this may cause overlaps with nodes which have physics enabled. The extra edges are set to dashed in this example to ensure everything is still visible.

Hidden - An example of using the hidden option is https://jsfiddle.net/xfcuvtgk/ and also incorporated into this post below. Edges set to hidden are still part of the physics calculation when the layout is generated, which you mentioned wasn't desired, however this does mean they fit nicely when later displayed.

// 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" },
]);

// create an array with edges
var edges = new vis.DataSet([
  { from: 1, to: 3 },
  { from: 1, to: 2 },
  { from: 2, to: 4 },
  { from: 2, to: 5 },
  { from: 3, to: 3 },
  { from: 4, to: 5, color: 'red', hidden: true, arrows: 'to', extra: true },
  { from: 3, to: 5, color: 'red', hidden: true, arrows: 'to', extra: true },
  { from: 1, to: 5, color: 'red', hidden: true, arrows: 'to', extra: true }
]);

// create a network
var container = document.getElementById("mynetwork");
var data = {
  nodes: nodes,
  edges: edges,
};
var options = {};
var network = new vis.Network(container, data, options);

document.getElementById('extraEdges').onclick = function() {
    // Extract the list of extra edges
  edges.forEach(function(edge){
    if(edge.extra){
        // Toggle the hidden value
      edge.hidden = !edge.hidden;
      
      // Update edge back onto data set
      edges.update(edge);
    }
  });
}
#mynetwork {
  width: 600px;
  /* Height adjusted for Stack Overflow inline demo */
  height: 160px;
  border: 1px solid lightgray;
}
<script src="https://visjs.github.io/vis-network/standalone/umd/vis-network.min.js"></script>
<button id="extraEdges">Show/Hide Extra Edges</button>
<div id="mynetwork"></div>
Chris C
  • 1,662
  • 1
  • 15
  • 17
  • Hi Chris, I really like your answer, but it only works if I doesn't use a layout. As soon as I configure a layout in `options`, the layout updates when adding the extra edges. – Chau Jan 04 '22 at 08:06
  • I see what you mean if you are referring to hierarchical layout set at `options.layout.hierarchical.enabled`, this appears to always recalculate when new edges are displayed regardless of these settings. Is this the option you are using, or another layout option? – Chris C Jan 04 '22 at 09:17
  • I am indeed using the hierarchical layout. As far as I can tell the code for the layout doesn't take into account the `physics: false` on the extra edges. I have [changed](https://jsfiddle.net/j75vs34a/) your example a bit and if no physics was applied to the extra edge, I would expect `node 5` to return to its original position when stabilizing - but it doesn't. Maybe a `layout: true/false` property would be the most appropriate solution here? – Chau Jan 05 '22 at 08:03
  • Looking at this a bit further I don't see any way without creating an overlay. The network maintains the hierarchical layout for all edges regardless. – Chris C Jan 06 '22 at 22:33
  • I put together a quick example using an overlay which therefore works with hierarchical layout, it's fair if this isn't an approach you're interested in though. I've posted it as a separate answer as I believe this answer may still be useful, plus it's a wildly different approach. – Chris C Jan 06 '22 at 23:23
1

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>
Chris C
  • 1,662
  • 1
  • 15
  • 17
  • 1
    As you suggest this is definitely not the solution I am looking for. On the other hand it looks like the *best* solution so far and I will accept this answer to let the question rest until a better solution surfaces. Thanks for the elaborate answers - I appreciate it! – Chau Jan 14 '22 at 11:01