d3.js - Add background rectangle on force directed diagram groups

593 Views Asked by At

I would like to add a background rectangle to group 2, the idea is add a g element and append all group 2 nodes to the g element, then use g element bbox to draw a rectangle.

But I don't know how to move exist nodes to g element! (Maybe not possible?).

Example code as below:

var graph = {
  nodes:[
    {id: "A",name:'AAAA', group: 1},
    {id: "B", name:'BBBB',group: 2},
    {id: "C", name:'CCCC',group: 2},
    {id: "D", name:'DDDD',group: 2},
    {id: "E", name:'EEEE',group: 2},
    {id: "F", name:'FFFF',group: 3},
    {id: "G", name:'GGGG',group: 3},
    {id: "H", name:'HHHH',group: 3},
    {id: "I", name:'IIII',group: 3}
  ],
  links:[
    {source: "A", target: "B", value: 1},
    {source: "A", target: "C", value: 1},
    {source: "A", target: "D", value: 1},
    {source: "A", target: "E", value: 1},
    {source: "A", target: "F", value: 1},
    {source: "A", target: "G", value: 1},
    {source: "A", target: "H", value: 1},
    {source: "A", target: "I", value: 1},
  ]
};

var width = 400
var height = 200
var svg = d3.select('body').append('svg')
.attr('width',width)
.attr('height',height)
.style('border','1px solid red')

var color = d3.scaleOrdinal(d3.schemeCategory10);

var simulation = d3.forceSimulation()
.force("link", d3.forceLink().id(function(d) { return d.id; }).distance(100))
.force("charge", d3.forceManyBody())
.force("x", d3.forceX(function(d){
  if(d.group === 2){
    return width/3
  } else if (d.group === 3){
    return 2*width/3
  } else {
    return width/2 
  }
}))
.force("y", d3.forceY(height/2))
.force("center", d3.forceCenter(width / 2, height / 2));

var g = svg.append("g")
.attr("class", "nodes")
.selectAll("circle")
.data(graph.nodes)
.enter()

var w = 80
var txts = g.append('text')
.attr('class','text')
.attr('text-anchor','middle')
.attr("dominant-baseline", "central")
.attr('fill','black')
.text(d => d.name)
.each((d,i,n) => {
  var bbox = d3.select(n[i]).node().getBBox()
  var margin = 4
  bbox.x -= margin
  bbox.y -= margin
  bbox.width += 2*margin
  bbox.height += 2*margin
  if (bbox.width < w) {
    bbox.width = w
  }
  d.bbox = bbox
})

var node = g
.insert('rect','text')
.attr('stroke','black')
.attr('width', d => d.bbox.width)
.attr('height',d => d.bbox.height)
.attr("fill", function(d) { return color(d.group); })
.attr('fill-opacity',0.3)
.call(d3.drag()
      .on("start", dragstarted)
      .on("drag", dragged)
      .on("end", dragended));

var link = svg.append("g")
.attr("class", "links")
.attr('stroke','black')
.selectAll("line")
.data(graph.links)
.enter().append("path")
.attr("stroke-width", function(d) { return Math.sqrt(d.value); });

simulation
  .nodes(graph.nodes)
  .on("tick", ticked);

simulation.force("link")
  .links(graph.links);

function ticked() {
  link
    .attr("d", function(d) { 
    var ax = d.source.x
    var ay = d.source.y
    var bx = d.target.x
    var by = d.target.y
    if (bx < ax) {
      ax -= w/2
      bx += w/2
    }else{
      ax += w/2
      bx -= w/2
    }
    var path = ['M',ax,ay,'L',bx,by]
    return path.join(' ')
  })

  txts.attr('x',d => d.x)
    .attr('y',d => d.y)

  node
    .attr("x", function(d) { return d.x - d.bbox.width/2; })
    .attr("y", function(d) { return d.y - d.bbox.height/2; });

}

function dragstarted(event,d) {
  if (!event.active) simulation.alphaTarget(0.3).restart();
  d.fx = d.x;
  d.fy = d.y;
}

function dragged(event,d) {
  d.fx = event.x;
  d.fy = event.y;
}

function dragended(event,d) {
  if (!event.active) simulation.alphaTarget(0);
  d.fx = null;
  d.fy = null;
}
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/6.7.0/d3.min.js"></script>

1

There are 1 best solutions below

1
Andrew Reid On

The force simulation doesn't use the DOM for anything. It merely calculates where nodes should be, how you render them, if you render them, is up to you. So putting some nodes in a g but not others is not a problem. For example, we could add a g for group 2, run through all the nodes, detach them from the DOM if they are from group 2 and reappend them to the new g:

var parent = d3.select("g").append("g").lower();
node.each(function(d) {
    if (d.group == 2) {
      d3.select(this).remove();
      parent.append((d)=>this);      
    }
  })

Then all we need to do is create a background rectangle:

var background = d3.select("g")
  .append("rect")
  .lower()  // so it is behind the nodes.
  ....

And update it on tick with a new bounding box of the g, as shown below.

var graph = {
  nodes:[
    {id: "A",name:'AAAA', group: 1},
    {id: "B", name:'BBBB',group: 2},
    {id: "C", name:'CCCC',group: 2},
    {id: "D", name:'DDDD',group: 2},
    {id: "E", name:'EEEE',group: 2},
    {id: "F", name:'FFFF',group: 3},
    {id: "G", name:'GGGG',group: 3},
    {id: "H", name:'HHHH',group: 3},
    {id: "I", name:'IIII',group: 3}
  ],
  links:[
    {source: "A", target: "B", value: 1},
    {source: "A", target: "C", value: 1},
    {source: "A", target: "D", value: 1},
    {source: "A", target: "E", value: 1},
    {source: "A", target: "F", value: 1},
    {source: "A", target: "G", value: 1},
    {source: "A", target: "H", value: 1},
    {source: "A", target: "I", value: 1},
  ]
};

var width = 400
var height = 200
var svg = d3.select('body').append('svg')
.attr('width',width)
.attr('height',height)
.style('border','1px solid red')

var color = d3.scaleOrdinal(d3.schemeCategory10);

var simulation = d3.forceSimulation()
.force("link", d3.forceLink().id(function(d) { return d.id; }).distance(100))
.force("charge", d3.forceManyBody())
.force("x", d3.forceX(function(d){
  if(d.group === 2){
    return width/3
  } else if (d.group === 3){
    return 2*width/3
  } else {
    return width/2 
  }
}))
.force("y", d3.forceY(height/2))
.force("center", d3.forceCenter(width / 2, height / 2));

var g = svg.append("g")
.attr("class", "nodes")
.selectAll("circle")
.data(graph.nodes)
.enter()

var w = 80
var txts = g.append('text')
.attr('class','text')
.attr('text-anchor','middle')
.attr("dominant-baseline", "central")
.attr('fill','black')
.text(d => d.name)
.each((d,i,n) => {
  var bbox = d3.select(n[i]).node().getBBox()
  var margin = 4
  bbox.x -= margin
  bbox.y -= margin
  bbox.width += 2*margin
  bbox.height += 2*margin
  if (bbox.width < w) {
    bbox.width = w
  }
  d.bbox = bbox
})

var node = g
.insert('rect','text')
.attr('stroke','black')
.attr('width', d => d.bbox.width)
.attr('height',d => d.bbox.height)
.attr("fill", function(d) { return color(d.group); })
.attr('fill-opacity',0.3)
.call(d3.drag()
      .on("start", dragstarted)
      .on("drag", dragged)
      .on("end", dragended));

// Start Changes 1/2
var parent = d3.select("g").append("g").lower();
node.each(function(d) {
    if (d.group == 2) {
      d3.select(this).remove();
      parent.append((d)=>this);      
    }
  })
var background = d3.select("g")
  .append("rect")
  .lower()
  .attr("ry", 5)
  .attr("rx", 5)
  .attr("fill","#ccc")
  .attr("stroke","#999")
  .attr("stroke-width", 1);
// End Changes 1/2
  
      

var link = svg.append("g")
.attr("class", "links")
.attr('stroke','black')
.selectAll("line")
.data(graph.links)
.enter().append("path")
.attr("stroke-width", function(d) { return Math.sqrt(d.value); });

simulation
  .nodes(graph.nodes)
  .on("tick", ticked);

simulation.force("link")
  .links(graph.links);

function ticked() {
  link
    .attr("d", function(d) { 
    var ax = d.source.x
    var ay = d.source.y
    var bx = d.target.x
    var by = d.target.y
    if (bx < ax) {
      ax -= w/2
      bx += w/2
    }else{
      ax += w/2
      bx -= w/2
    }
    var path = ['M',ax,ay,'L',bx,by]
    return path.join(' ')
  })

  txts.attr('x',d => d.x)
    .attr('y',d => d.y)

  node
    .attr("x", function(d) { return d.x - d.bbox.width/2; })
    .attr("y", function(d) { return d.y - d.bbox.height/2; });
    
// Start changes 2/2
var box = parent.node().getBBox() 

background.attr("width", box.width+10)
  .attr("height",box.height+10)
  .attr("x", box.x-5)
  .attr("y", box.y-5);
//End Changes 2/2

}

function dragstarted(event,d) {
  if (!event.active) simulation.alphaTarget(0.3).restart();
  d.fx = d.x;
  d.fy = d.y;
}

function dragged(event,d) {
  d.fx = event.x;
  d.fy = event.y;
}

function dragended(event,d) {
  if (!event.active) simulation.alphaTarget(0);
  d.fx = null;
  d.fy = null;
}
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/6.7.0/d3.js"></script>

If you wanted more than one group, or had dynamic data, this approach isn't ideal - the join or the data structure would need to be modified a bit to make a more canonical approach work - I might revisit it later tonight with an alternative. As is, this solution is likely the least invasive with respect to your existing code.