带有力导向图的鱼眼效应:直到图稳定后才生效

问题描述

我正在创建一个具有鱼眼效果的图形,用户在其光标下有一个永久缩放,并且可以四处移动图形节点。

Here's what I have: (ObservableHQ)

以片段形式:

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

我使用了 Bostock 的鱼眼效果,只要图形是静态的,它就可以正常工作。但是,如果力模拟正在运行,则不起作用,从而产生以下效果

enter image description here

我尝试将鱼眼效果重构为一种力,并直接在力模拟中使用它,如下所示:

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

但是这会产生奇怪的结果,反而让节点从我的光标处跑开。

如何使用具有鱼眼效果的力导向图?

感谢您的宝贵时间!

解决方法

关键挑战是您有两个同时工作的定位源来移动节点:设置位置以实现鱼眼效果的鼠标移动函数和设置位置以反映更新的力布局的刻度函数。由于tick函数不断被触发,这可能解释了你的评论,鱼眼效应只在力冷却时起作用:tick函数不再被调用,两种定位方法之间没有冲突。

要消除竞争定位方法,最好在强制冷却期间使用tick函数,在强制冷却后,使用鼠标事件本身进行定位:因为鼠标在期间不会总是移动模拟之后,滴答声肯定不会被触发。

另一个挑战是,如果鼠标停止移动,尽管力布局移动,鱼眼效果也不会更新:我们需要在每个刻度更新鱼眼效果,以反映当节点进出时哪些节点受到影响重点地区。无论鼠标是否移动,都需要进行此更新。

如前所述,使用力创建鱼眼并不是很好:光标会强制节点改变 x/y 属性,而不仅仅是扭曲它们的外观:鱼眼效应不应干扰力布局的力/位置数据。

鉴于这些限制,一个快速的解决方案也许可以随着时间的推移而变得更优雅:

  • 跟踪上次鼠标移动位置或鼠标是否已退出 SVG:
  let xy = false;

  svg.on("mousemove",function() {  xy = d3.mouse(this); })
     .on("mouseleave",function() {  xy = false; })
  • 在力定位期间,数据基于力和最近已知的鼠标位置来实现鱼眼:
    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); 
    }

  • 然后,当模拟结束时,使用鼠标移动事件计算静态节点上的鱼眼效果,因为滴答不再触发:
    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,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 = 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,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>