0

I am creating a force directed layout where I would like to split a series of circles into three different groups. Each group then has a corresponding center point.

enter image description here

So far so good. Now I would like to constrain the circles in each group to a type of bounding box. Please see image below.

enter image description here

I think the way to go is to use a custom force. This is where I would need some help. Has anyone been able to do this?

This is what I have so far:

.force('custom_1', (alpha) => {
    for(let node of data) { 
       let centerX = groupCenterPoints[node.group].x;
       let minX = centerX - ( (w/3)/2 );
       let maX = centerX + ( (w/3)/2 );

       //not sure how to modify node.vx here?
    }
});

function createData(max) {
  let data = [];
  
  for(let i = 0 ; i < max; i++) {
     data.push({
      group: chance.character({ pool: 'abc' }),
      r: 10
     });
  }
  
  return data;
}

let data = createData(150);

let svg = d3.select('#container');

let w = parseInt( svg.style('width') );
let h = parseInt( svg.style('height') );

let groupCenterPoints = {
  a: {x: ((w/3)/2) + (w/3 * 0), y: h/2},
  b: {x: ((w/3)/2) + (w/3 * 1), y: h/2},
  c: {x: ((w/3)/2) + (w/3 * 2), y: h/2}
}

let nodes = svg.selectAll('.nodes')
  .data(data)
  .enter()
  .append('circle')
  .attr('r', (d) => { return d.r; })
  .attr('fill', 'none')
  .attr('stroke', 'black');
  
 
let simulation = d3.forceSimulation()
   .force('x', d3.forceX((d) => { return groupCenterPoints[d.group].x }))
   .force('y', d3.forceY((d) => { return groupCenterPoints[d.group].y }))
   .force('collision', d3.forceCollide().radius((d) => { return d.r + 2 }))
      .force('custom_1', (alpha) => {
        for(let node of data) {
        
          let centerX = groupCenterPoints[node.group].x;
          let minX = centerX - ( (w/3)/2 );
          let maX = centerX + ( (w/3)/2 );
          
          //not sure how to modify node.vx here?
        }
      });
      
      
simulation
   .nodes(data)
   .on('tick', () => {
    nodes.attr('cx', (d) => { return d.x; })
        nodes.attr('cy', (d) => { return d.y; })
   });
#container {
  width:100vw;
  height: 100vh;
    
  margin: 0;
  padding: 0;
}
<script src="https://chancejs.com/chance.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/5.7.0/d3.min.js"></script>

<svg id="container"></svg>
Ivan Bacher
  • 5,855
  • 9
  • 36
  • 56
  • related but no answer https://stackoverflow.com/questions/33075728/confining-force-layouts-groups-of-nodes-to-area-of-svg-element-in-d3 – Ivan Bacher Jan 21 '20 at 16:55
  • As a practical question, I'd ask why you're writing your own code instead of using one of several JS graphics libraries that already let you write force directed layout with SVG support. And then as a follow-up, my first idea would probably be "have a look at how one of those does it, since they're all open source anyway"? – Mike 'Pomax' Kamermans Jan 21 '20 at 17:12
  • I haven't worked with force yet. But as a different strategy, you could perhaps split your data to be included inside three different `` containers and use basic CSS to separate them – JoeBe Jan 22 '20 at 05:06

1 Answers1

1

Here is a solution to a similar visualization requirement, however the functional requirement was slightly different.

Here, nodes are grouped into different "zones", as in your case. The user is then required to drag nodes from one zone to another, but some zones, based on node and zone characteristics, are disabled or "dead".

It uses a standard force

that.simulation = this.d3.forceSimulation()
        .force('collide',d3.forceCollide()
                           .radius(d => {
                             return d.type === 'count' ? 60 : 30
                           }))
        // .force('charge',d3.forceManyBody()
        //                    .strength(10))
        .on('tick',ticked)

The code below is the dragged handler, which restricts nodes from dead zones.

It is NOT fully debugged, and hasn't been touched since October, but hopefully it will help.

Below the dragged handler is the ticked function, which positions elements initially, and after dragging.

dragged

The key part is about halfway down, after these comments:

      // positioning of dragged node under cursor
      // respecting all deadzone and perimiter boundaries and node radius
// drag in progress handler
    // d = the d3 object associated to the dragged circle
    function dragged(d) {
      that.trace()
      let debugcoord = [10,20]
      let r  = RAD_KEY + 4
      let dx = d3.event.dx
      let dy = d3.event.dy
      let fx = d.fx
      let fy = d.fy
      let nx = fx+dx
      let ny = fy+dy

      // DEBUGGING CODE BELOW, DO NOT DELETE
      // let curX = Math.round(d3.event.sourceEvent.clientX-that.g.node().getClientRects()[0].left)
      // let curY = Math.round(d3.event.sourceEvent.clientY-that.g.node().getClientRects()[0].top)
      // that.g.selectAll('text.coordinates').remove()
      // that.g
      // .append('text')
      // .classed('coordinates',true)
      // .attr('x',debugcoord[0])
      // .attr('y',debugcoord[1])
      // .text(`${curX},${curY}`)

      // check out this fx,fy description for reference:
      //   https://stackoverflow.com/a/51548821/4256677
      // deadzones are the inverse of livezones
      let deadzone = []
      // the names of all valid transitions, used to calculate livezones
      let trans    = !!d.trans ? d.trans.map(t => t.to.name) : []
      // the name value of the workflow status of the dragged node
      let name     = d.name
      // all the zones:
      let zones    = d3.selectAll('g.zone.group > rect.zone')
      // the maximum x+width value of all nodes in 'zones' array
      let right    = d3.max(zones.data(), n => parseFloat(n.x)+parseFloat(n.width))
      // the maximum y+height value of all nodes in 'zones' array
      let bottom   = d3.max(zones.data(), n => parseFloat(n.y)+parseFloat(n.height))

      // the zones which represent valid future states to transition
      // the dragged node (issue)
      let livezones = zones.filter(function(z,i,nodes) {
        // the current zone object
        let zone     = d3.select(this)
        // the name of the current zone object
        let zonename = zone.attr('data-zone')
        // boolean referring to the current zone representing a valid future
        // state for the node
        let isLive = trans.includes(zonename) || name == zonename
        // deadzone recognition and caching
        if(!isLive)
        {
          let coords = {name:zonename,
                        x1:parseFloat(z.x),
                        x2:parseFloat(z.x)+parseFloat(z.width),
                        y1:parseFloat(z.y),
                        y2:parseFloat(z.y)+parseFloat(z.height)}
          deadzone.push(coords)
        }
        return isLive
      }).classed('live',true) // css for livezones
      d3.selectAll('rect.zone:not(.live)').classed('dead',true) // css for deadzones

      // positioning of dragged node under cursor
      // respecting all deadzone and perimiter boundaries and node radius
      that.nodes.filter(function(d) { return d.dragging; })
      .each(function(d) {
        if(deadzone.length > 0)
        {
          d.fx += deadzone.reduce((a,c) => {
            a =
                // node is in graph
                (nx > 0 + r && nx < right - r)
                // deadzone is in left column and node is to the right or above or below
                && ((c.x1 == 0 && (nx > c.x2 + r || ny < c.y1 - r || ny > c.y2 + r))
                    // or deadzone is in the right column and node is to the left, above or below
                    || (c.x2 == right && (nx < c.x1 - r || ny < c.y1 - r || ny > c.y2 + r))
                    // or deadzone is not in left column and node is to the left, right, above or below
                    || (c.x1 > 0 && (nx < c.x1 - r || nx > c.x2 + r || ny < c.y1 - r || ny > c.y2 + r))
                  )
                ? dx : 0
            return a
          },0)
          d.fy += deadzone.reduce((a,c) => {
            a =
                // node is in graph
                (ny > 0 + r && ny < bottom - r)
                // deadzone is in top row and node is below or to the left or right
                && ((c.y1 == 0 && (ny > c.y2 + r || nx < c.x1 - r || nx > c.x2 + r))
                    // or deadzone is in the right column and node is to the left, above or below
                    || (c.y2 == bottom && (ny < c.y1 - r || nx < c.x1 - r || nx > c.x2 + r))
                    // or deadzone is not in top row and node is above, below, left or right
                    || (c.y1 > 0 && (ny < c.y1 - r || ny > c.y2 + r || nx < c.x1 - r || nx > c.x2 + r))
                  )
                ? dy : 0

            // DEBUGGING CODE BELOW, DO NOT DELETE
            // that.g
            // .append('text')
            // .classed('coordinates',true)
            // .attr('x',debugcoord[0])
            // .attr('y',debugcoord[1]+25)
            // .text(`${Math.round(nx)},${Math.round(ny)} vs ${r},${r},${right-r},${bottom-r}`)
            // that.g
            // .append('text')
            // .classed('coordinates',true)
            // .attr('x',10)
            // .attr('x',debugcoord[0])
            // .attr('y',debugcoord[1]+50)
            // .text(`dz coords: ${c.x1},${c.y1} ${c.x2},${c.y2}`)
            // that.g
            // .append('text')
            // .classed('coordinates',true)
            // .attr('x',debugcoord[0])
            // .attr('y',debugcoord[1]+75)
            // .text(c.name)
            return a
          },0)
        }
        else
        {
          d.fx += dx
          d.fy += dy
        }

      })
    }

ticked

The key part is after the comment // if were no longer dragging

function ticked(e) {

          if(!!that.links && that.links.length > 0)
          {
            that.links
            .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; })
          }

          that.nodes
            .attr("cx", function(d) { return d.x; })
            .attr("cy", function(d) { return d.y; })
            .each(function(d) {
              if(typeof d.selected === 'undefined')
                d.selected = false
              if(typeof d.previouslySelected === 'undefined')
                d.previouslySelected = false
            })

          that.labels
            .attr("x", function(d) { return d.x })
            .attr("y", function(d) {
              return d.type === 'count' ? d.y+6 : d.y+4
            })
            .attr('class',(d) => { return that.getClassFromNodeName(d.name)})
            .classed('count', (d) => {
              return d.type === 'count' ? true : false
            })

          // if were no longer dragging
          if(!that.dragging)
          {
            let k = 4*this.alpha()

            that.nodes.each(function(n,i) {
              let zclass = that.getClassFromNodeName(n.name)
              let z = that.zones[zclass]
              n.x += (z.x + z.width/2 - n.x) * k
              n.y += (z.y + z.height/2 - n.y) * k
            })
          }
          that.nodes
            // .each(pos)
            .attr('cx',d => { return d.x }) //boundary(d,'x')})
            .attr('cy',d => { return d.y }) //boundary(d,'y')})
          that.labels
            // .each(pos)
            .attr('x',d => { return d.x }) //boundary(d,'x')})
            .attr('y',d => { return d.y + (d.type === 'count'?6:4) }) //boundary(d,'y') + (d.type === 'count'?6:4)})
        }
varontron
  • 1,120
  • 7
  • 21