Search code examples
d3.jsforce-layout

Fisheye effect with force-directed graph : not taking effect until the graph settles


I'm creating a graph with a fisheye effect, where the user has a permanent zoom under his cursor, and can move the graph nodes around.

Here's what I have: (ObservableHQ)

And in snippet form:

d3.json("https://gist.githubusercontent.com/mbostock/4062045/raw/5916d145c8c048a6e3086915a6be464467391c62/miserables.json").then(draw);
  
function draw(data) {
  
  const fisheye = fisheyeO.circular()
  .radius(100)
  .distortion(5);
  
  const height = 400;
  const width = 500;
  
  data.nodes.forEach(d=>{d.fisheye={x:0,y:0,z:0}})

  const simulation = d3.forceSimulation(data.nodes)
      .alphaDecay(0.0125)
      .alphaMin(0.01)
  .force("link", d3.forceLink(data.links).id(d => d.id))
  .force("charge", d3.forceManyBody())
  .force("x", d3.forceX(width/2))
  .force("y", d3.forceY(height/2));

  const svg = d3.select("body").append("svg")
  .attr("viewBox", [0, 0, width, height])

  const link = svg.append("g")
  .attr("stroke", "#999")
  .attr("stroke-opacity", 0.6)
  .selectAll("line")
  .data(data.links)
  .join("line")
  .attr("stroke-width", 2);
  
  const node = svg.append("g")
  .attr("stroke", "#fff")
  .attr("stroke-width", 1.5)
  .selectAll("circle")
  .data(data.nodes)
  .join("circle")
  .attr("r", 5)
  .attr("fill", "black")


  svg.on("mousemove", function() {
    fisheye.focus(d3.mouse(this));

    node.each(function(d) { d.fisheye = fisheye(d); })
      .attr("cx", function(d) { return d.fisheye.x; })
      .attr("cy", function(d) { return d.fisheye.y; })
      .attr("r", function(d) { return d.fisheye.z * 4.5; });

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

  simulation.on("tick", () => {
    link
      .attr("x1", d => d.source.x)
      .attr("y1", d => d.source.y)
      .attr("x2", d => d.target.x)
      .attr("y2", d => d.target.y);

    node
      .attr("cx", d => d.x)
      .attr("cy", d => d.y);
  });

}


const fisheye0 = fisheyeO = {
    circular: () => {
      var radius = 200,
          distortion = 2,
          k0,
          k1,
          focus = [0, 0];

      function fisheye(d) {
        var dx = d.x - focus[0],
            dy = d.y - focus[1],
            dd = Math.sqrt(dx * dx + dy * dy);
        if (!dd || dd >= radius) return {x: d.x, y: d.y, z: dd >= radius ? 1 : 10};
        var k = k0 * (1 - Math.exp(-dd * k1)) / dd * .75 + .25;
        return {x: focus[0] + dx * k, y: focus[1] + dy * k, z: Math.min(k, 10)};
      }

      function rescale() {
        k0 = Math.exp(distortion);
        k0 = k0 / (k0 - 1) * radius;
        k1 = distortion / radius;
        return fisheye;
      }

      fisheye.radius = function(_) {
        if (!arguments.length) return radius;
        radius = +_;
        return rescale();
      };

      fisheye.distortion = function(_) {
        if (!arguments.length) return distortion;
        distortion = +_;
        return rescale();
      };

      fisheye.focus = function(_) {
        if (!arguments.length) return focus;
        focus = _;
        return fisheye;
      };

      return rescale();
    }
}
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/5.16.0/d3.min.js"></script>

I've used the fisheye effect from Bostock, which works fine as long as the graph is static. However, it doesn't work if the force simulation is running, giving this effect:

enter image description here

I've tried refactoring the fisheye effect as a force instead and using it directly in the force simulation instead, something like this:

function forceFisheye(fisheye) {
  let nodes;

  function force() {
    let i;
    let n = nodes.length;
    let node;

    for (i = 0; i < n; ++i) {
      node = nodes[i];
      let { x, y, z } = fisheye(node);
      node.x = x;
      node.y = y;
      node.z = z;
    }
  }

  force.initialize = function (_) {
    nodes = _;
  };

  return force;
}

let fisheye = fisheye();

// ...
d3.forceSimulation()
    .force("fisheye", forceFisheye(fisheye));

but this gives odd results, making the nodes chase away from my cursor instead.

How can I use a force-directed graph with a fisheye effect?

Thank you for your time!


Solution

  • The key challenge is that you have two sources of positioning working at the same time to move the nodes: a mouse move function that sets positions to achieve a fisheye effect and a tick function that sets positions to reflect an udpated force layout. Since the tick function is triggered constantly, this likely explains your comment that the fisheye effect only works when the force cools down: the tick function is no longer called and there is no conflict between the two positioning methods.

    To remove competing positioning methods it is probably best to use the tick function during the force cool down, and after the force has cooled down, to use the mouse event itself to position: as the mouse won't always be moving during the simulation, and the ticks certainly won't be firing after.

    Another challenge is that if the mouse stops moving the fisheye effect doesn't update despite motion of the force layout: we need to update the fisheye effect every tick to reflect what nodes are affected as the nodes drift in and out of the focus area. This update needs to occur regardless of whether the mouse moves or not.

    As noted, using a force to create a fisheye is not great: the cursor forces the nodes to change x/y properties instead of merely distorting their appearance: the fisheye effect should not interfere with the force layout's forces/positional data.

    Given these restrictions, a quick solution that perhaps could be cleaned up into something more elegant with time would be to:

    • Track last mouse move position or if mouse has exited the SVG:
      let xy = false;
    
      svg.on("mousemove", function() {  xy = d3.mouse(this); })
         .on("mouseleave", function() {  xy = false; })
    
    • During the force position the data based on both the force and the most recently known mouse position to implement the fisheye:
        simulation.on("tick",position)
    
        function position() {
            if(xy) {
                fisheye.focus(xy);
                node.each(d=>{ d.fisheye = fisheye(d); })
              }
              else node.each(d=>{d.fisheye={x:0,y:0,z:0}})
    
              link
                .attr("x1", d => d.source.fisheye.x || d.source.x)
                .attr("y1", d => d.source.fisheye.y || d.source.y)
                .attr("x2", d => d.target.fisheye.x || d.target.x)
                .attr("y2", d => d.target.fisheye.y || d.target.y);
    
              node
                .attr("cx", d => d.fisheye.x || d.x)
                .attr("cy", d => d.fisheye.y || d.y); 
        }
    
    
    • Then when the simulation ends, use the mouse move event to calculate the fisheye effect on the static nodes as the tick no longer is firing:
        simulation.on("end", function() {
           svg.on("mousemove.position", position);
         })
    

    d3.json("https://gist.githubusercontent.com/mbostock/4062045/raw/5916d145c8c048a6e3086915a6be464467391c62/miserables.json").then(draw);
      
    function draw(data) {
      
      const fisheye = fisheyeO.circular()
      .radius(100)
      .distortion(5);
      
      const height = 400;
      const width = 500;
      
      data
    
      const simulation = d3.forceSimulation(data.nodes)
          .alphaDecay(0.001)
          .alphaMin(0.01)
      .force("link", d3.forceLink(data.links).id(d => d.id))
      .force("charge", d3.forceManyBody())
      .force("x", d3.forceX(width/2))
      .force("y", d3.forceY(height/2));
    
      const svg = d3.select("body").append("svg")
      .attr("viewBox", [0, 0, width, height])
    
      const link = svg.append("g")
      .attr("stroke", "#999")
      .attr("stroke-opacity", 0.6)
      .selectAll("line")
      .data(data.links)
      .join("line")
      .attr("stroke-width", 2);
      
      const node = svg.append("g")
      .attr("stroke", "#fff")
      .attr("stroke-width", 1.5)
      .selectAll("circle")
      .data(data.nodes)
      .join("circle")
      .attr("r", 5)
      .attr("fill", "black")
    
    
      let xy = false;
    
      svg.on("mousemove", function() {  xy = d3.mouse(this); })
         .on("mouseleave", function() {  xy = false; })
    
      simulation.on("tick", position)
      .on("end", function() {
        svg.on("mousemove.position", position);
      })
      
      function position() {
        if(xy) {
            fisheye.focus(xy);
            node.each(d=>{ d.fisheye = fisheye(d); })
          }
          else node.each(d=>{d.fisheye={x:0,y:0,z:0}})
    
          link
            .attr("x1", d => d.source.fisheye.x || d.source.x)
            .attr("y1", d => d.source.fisheye.y || d.source.y)
            .attr("x2", d => d.target.fisheye.x || d.target.x)
            .attr("y2", d => d.target.fisheye.y || d.target.y);
    
          node
            .attr("cx", d => d.fisheye.x || d.x)
            .attr("cy", d => d.fisheye.y || d.y);
      }
    
    }
    
    
    const fisheye0 = fisheyeO = {
        circular: () => {
          var radius = 200,
              distortion = 2,
              k0,
              k1,
              focus = [0, 0];
    
          function fisheye(d) {
            var dx = d.x - focus[0],
                dy = d.y - focus[1],
                dd = Math.sqrt(dx * dx + dy * dy);
            if (!dd || dd >= radius) return {x: 0, y: 0, z: dd >= radius ? 1 : 10};
            var k = k0 * (1 - Math.exp(-dd * k1)) / dd * .75 + .25;
            return {x: focus[0] + dx * k, y: focus[1] + dy * k, z: Math.min(k, 10)};
          }
    
          function rescale() {
            k0 = Math.exp(distortion);
            k0 = k0 / (k0 - 1) * radius;
            k1 = distortion / radius;
            return fisheye;
          }
    
          fisheye.radius = function(_) {
            if (!arguments.length) return radius;
            radius = +_;
            return rescale();
          };
    
          fisheye.distortion = function(_) {
            if (!arguments.length) return distortion;
            distortion = +_;
            return rescale();
          };
    
          fisheye.focus = function(_) {
            if (!arguments.length) return focus;
            focus = _;
            return fisheye;
          };
    
          return rescale();
        }
    }
    <script src="https://cdnjs.cloudflare.com/ajax/libs/d3/5.16.0/d3.min.js"></script>