Search code examples
d3.jsdragforce-layoutvoronoiclip-path

How to modify a d3 force layout with voronoi polygons to trigger events on grouped elements?


The goal is to combine d3 force simulation, g elements, and voronoi polygons to make trigger events on nodes easier, such as dragging, mouseovers, tooltips and so on with a graph that can be dynamically modified. This follows the d3 Circle Dragging IV example.

In the following code, when adding the clip path attribute to the g element and clippath elements:

  • Why does dragging not trigger on the cells?
  • Why do the nodes become obscured and the paths lose their styles on edges?
  • How can this be fixed to drag the nodes and trigger events on them like mouseovers?

var data = [
  {
    "index" : 0,
      "vx" : 0,
        "vy" : 0,
          "x" : 842,
            "y" : 106
  },
    {
      "index" : 1,
        "vx" : 0,
          "vy" : 0,
            "x" : 839,
              "y" : 56
    },
     {
        "index" : 2,
          "vx" : 0,
            "vy" : 0,
              "x" : 771,
                "y" : 72
      }
]

var svg = d3.select("svg"),
    width = +svg.attr("width"),
    height = +svg.attr("height");
  
var simulation = d3.forceSimulation(data)
	.force("charge", d3.forceManyBody())
	.force("center", d3.forceCenter(width / 2, height / 2))
	.on("tick", ticked);
  
var nodes = svg.append("g").attr("class", "nodes"),
    node = nodes.selectAll("g"),
    paths = svg.append("g").attr("class", "paths"),
    path = paths.selectAll("path");

var voronoi = d3.voronoi()
	.x(function(d) { return d.x; })
	.y(function(d) { return d.y; })
	.extent([[0, 0], [width, height]]);
  
var update = function() {

  node = nodes.selectAll("g").data(data);
    var nodeEnter = node.enter()
  	.append("g")
  	.attr("class", "node")
    .attr("clip-path", function(d, i) { return "url(#clip-" + i + ")"; });
  nodeEnter.append("circle");
  nodeEnter.append("text")
    .text(function(d, i) { return i; });  
  node.merge(nodeEnter); 
  
  path = paths.selectAll(".path")
  .data(data)
  .enter().append("clipPath")
    .attr("id", function(d, i) { return "clip-" + i; })
    .append("path")
    .attr("class", "path");
  
  simulation.nodes(data);
  simulation.restart();

}();
  
function ticked() {
	var node = nodes.selectAll("g");
  var diagram = voronoi(node.data()).polygons();
  
  paths.selectAll("path")
    .data(diagram)
    .enter()
    .append("clipPath")
    .attr("id", function(d, i) { return "clip-" + i; })
    .append("path")
    .attr("class", "path");

  paths.selectAll("path")
    .attr("d", function(d) { return d == null ? null : "M" + d.join("L") + "Z"; });
  
  node.call(d3.drag()
    .on("start", dragstarted)
    .on("drag", dragged)
    .on("end", dragended));  

  node
    .attr("transform", function(d) { return "translate(" + d.x + "," + d.y + ")" });
}

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;
}
svg {
  border: 1px solid #888888;  
}

circle {
  r: 3;
  cursor: move;
  fill: black;
}

.node {
  pointer-events: all;
}

path {
  fill: none;
  stroke: #999;
  pointer-events: all;
}
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/4.5.1/d3.js"></script>
<svg width="400" height="400"></svg>

(Separate question, but nesting the paths in the g elements as in the Circle Dragging IV element causes undesired positioning of the paths off to the side of the graph.)

In a related question, using polygons instead of paths and clippaths, I can get the dragging to work, but am trying to use the clippath version as a comparison and not sure what are the differences, other than clippath seems to be preferred by Mike Bostock (d3 creator).


Solution

  • Block version.

    • Why does dragging not trigger on the cells?
      • Because if the cell attribute has fill:none, then it must have pointer-events:all.
    • Why do the nodes become obscured and the paths lose their styles on edges?
      • Because the clip path was targeting the g elements position instead of the circles position.
    • How can this be fixed to drag the nodes and trigger events on them like mouseovers?
      • use path attr pointer-events: all, path { pointer-events: all; }
      • select the desired child element such as circle, or text, in the drag or tick event for positioning parent.select(child).attr('d' function(d) { ..do stuff.. });
      • use node id's for references to simplify data array updates or deletions node.data(data, function(d) { return d.id; })

    Thanks Andrew Reid for your help.

    var svg = d3.select("svg"),
        width = +svg.attr("width"),
        height = +svg.attr("height"),
        color = d3.scaleOrdinal(d3.schemeCategory10);
    
    var a = {id: "a"},
        b = {id: "b"},
        c = {id: "c"},
        data = [a, b, c],
        links = [];
    
    var simulation = d3.forceSimulation(data)
        .force("charge", d3.forceManyBody().strength(-10))
        .force("link", d3.forceLink(links).distance(200))
    		.force("center", d3.forceCenter(width / 2, height / 2))
        .alphaTarget(1)
        .on("tick", ticked);
    
    var link = svg.append("g").attr("class", "links").selectAll(".link"),
        node = svg.append("g").attr("class", "nodes").selectAll(".node");
        
    var voronoi = d3.voronoi()
    	.x(function(d) { return d.x; })
    	.y(function(d) { return d.y; })
    	.extent([[-1, 1], [width + 1, height + 1]]);
    
    update();
    
    d3.timeout(function() {
      links.push({source: a, target: b}); // Add a-b.
      links.push({source: b, target: c}); // Add b-c.
      links.push({source: c, target: a}); // Add c-a.
      update();
    }, 1000);
    
    d3.interval(function() {
      data.pop(); // Remove c.
      links.pop(); // Remove c-a.
      links.pop(); // Remove b-c.
      update();
    }, 5000, d3.now());
    
    d3.interval(function() {
      data.push(c); // Re-add c.
      links.push({source: b, target: c}); // Re-add b-c.
      links.push({source: c, target: a}); // Re-add c-a.
      update();
    }, 5000, d3.now() + 1000);
    
    function update() {
    
      node = node.data(data, function(d) { return d.id; });
      node.exit().remove();
      var nodeEnter = node.enter().append("g")
      	.attr("class", "node")
        .on("mouseover", mouseover)
        .on("mouseout", mouseout);
      nodeEnter.append("circle").attr("fill", function(d) { return color(d.id); }).attr("r", 8);
      nodeEnter.append("text")
        .attr("dx", 12)
        .attr("dy", ".35em")
      	.text(function(d) { return d.id; });
      nodeEnter.append("path").attr("class", "path");
      nodeEnter.call(d3.drag()
                     .on("start", dragstarted)
                     .on("drag", dragged)
                     .on("end", dragended));
      node = node.merge(nodeEnter);
    
    
      // Apply the general update pattern to the links.
      link = link.data(links, function(d) { return d.source.id + "-" + d.target.id; });
      link.exit().remove();
      link = link.enter().append("line").merge(link);
    
      // Update and restart the simulation.
      simulation.nodes(data);
      simulation.force("link").links(links);
      simulation.alpha(1).restart();
    }
    
    function mouseover(d) {
    	d3.select(this).raise().classed("active", true);
    }
    
    function mouseout(d) {
    	d3.select(this).raise().classed("active", false);
    }
    
    function dragstarted(d) {
      if (!d3.event.active) simulation.alphaTarget(0.3).restart();
      d.fx = d.x;
      d.fy = d.y;
    }
    
    function dragged(d) {
      d3.select(this).select("circle").attr("cx", d.fx = d3.event.x).attr("cy", d.fy = d3.event.y);
    }
    
    function dragended(d) {
      if (!d3.event.active) simulation.alphaTarget(0);
      d.fx = null;
      d.fy = null;
    }
    
    function ticked() {
      node.select("circle")
      	.attr("cx", function(d) { return d.x; })
        .attr("cy", function(d) { return d.y; });
        
      node.select("path")
      	.data(voronoi.polygons(data))
        .attr("d", function(d) { return d == null ? null : "M" + d.join("L") + "Z"; });
        
      node.select("text")
      	.attr("transform", function(d) { return "translate(" + d.x + "," + d.y + ")" });
    
      link.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; });
    }
    path {
      pointer-events: all;
      fill: none;
      stroke: #666;
      stroke-opacity: 0.2;
    }
    
    .active path {
      fill: #111;  
      opacity: 0.05;
    }
    
    .active text {
      visibility: visible;
    }
    
    .active circle {
      stroke: #000;
      stroke-width: 1.5px;
    }
    
    svg {
      border: 1px solid #888;  
    }
    
    .links {
      stroke: #000;
      stroke-width: 1.5;
    }
    
    .nodes {
      stroke-width: 1.5;
    }
    
    text {
      pointer-events: none;
      font: 1.8em sans-serif;
      visibility: hidden;
    }
    <script src="https://cdnjs.cloudflare.com/ajax/libs/d3/4.5.0/d3.min.js"></script>
    <svg width="400" height="400"></svg>