Search code examples
javascriptd3.jsd3-force-directed

How to make all the nodes circle the center node?


I'm trying to make a force directed graph where the children and grandchildren nodes are circling/orbiting the parent. Meanwhile the parent node is connected to its children nodes, and each children nodes are connected to each of their grandchildren.

Visually, it would look something like this:

enter image description here

I've tried meddling with the default force directed graph (both here and there) but it seems like there is no way to order them neatly in circle/orbit like the visual I'm trying to make.

I tried looking up the orbit code, but it seems like it requires a completely different approach.

Here is my fiddle and code: https://jsfiddle.net/znqkcLhs/

function getNeighbors(node) {
  return links.reduce(function (neighbors, link) {
      if (link.target.id === node.id) {
        neighbors.push(link.source.id)
      } else if (link.source.id === node.id) {
        neighbors.push(link.target.id)
      }
      return neighbors
    },
    [node.id]
  )
}

function isNeighborLink(node, link) {
  return link.target.id === node.id || link.source.id === node.id
}


function getNodeColor(node, neighbors) {
  if (Array.isArray(neighbors) && neighbors.indexOf(node.id) > -1) {
    return node.level === 1 ? 'blue' : 'green'
  }

  return node.level === 1 ? 'red' : 'gray'
}


function getLinkColor(node, link) {
  return isNeighborLink(node, link) ? 'green' : '#E5E5E5'
}

function getTextColor(node, neighbors) {
  return Array.isArray(neighbors) && neighbors.indexOf(node.id) > -1 ? 'green' : 'black'
}

var width = window.innerWidth
var height = window.innerHeight

var svg = d3.select('svg')
svg.attr('width', width).attr('height', height)

// simulation setup with all forces
var linkForce = d3
  .forceLink()
  .id(function (link) { return link.id })
  .strength(function (link) { return link.strength })

var simulation = d3
  .forceSimulation()
  .force('link', linkForce)
  .force('charge', d3.forceManyBody().strength(-120))
  .force('center', d3.forceCenter(width / 2, height / 2))

var dragDrop = d3.drag().on('start', function (node) {
  node.fx = node.x
  node.fy = node.y
}).on('drag', function (node) {
  simulation.alphaTarget(0.7).restart()
  node.fx = d3.event.x
  node.fy = d3.event.y
}).on('end', function (node) {
  if (!d3.event.active) {
    simulation.alphaTarget(0)
  }
  node.fx = null
  node.fy = null
})

function selectNode(selectedNode) {
  var neighbors = getNeighbors(selectedNode)

  // we modify the styles to highlight selected nodes
  nodeElements.attr('fill', function (node) { return getNodeColor(node, neighbors) })
  textElements.attr('fill', function (node) { return getTextColor(node, neighbors) })
  linkElements.attr('stroke', function (link) { return getLinkColor(selectedNode, link) })
}

var linkElements = svg.append("g")
  .attr("class", "links")
  .selectAll("line")
  .data(links)
  .enter().append("line")
    .attr("stroke-width", function(link) { return link.value; })
	  .attr("stroke", "rgba(50, 50, 50, 0.2)")

var nodeElements = svg.append("g")
  .attr("class", "nodes")
  .selectAll("circle")
  .data(nodes)
  .enter().append("circle")
    .attr("r", 10)
    .attr("fill", getNodeColor)
    .call(dragDrop)
    .on('click', selectNode)

var textElements = svg.append("g")
  .attr("class", "texts")
  .selectAll("text")
  .data(nodes)
  .enter().append("text")
    .text(function (node) { return  node.label })
	  .attr("font-size", 15)
	  .attr("dx", 15)
    .attr("dy", 4)

simulation.nodes(nodes).on('tick', () => {
  nodeElements
    .attr('cx', function (node) { return node.x })
    .attr('cy', function (node) { return node.y })
  textElements
    .attr('x', function (node) { return node.x })
    .attr('y', function (node) { return node.y })
  linkElements
    .attr('x1', function (link) { return link.source.x })
    .attr('y1', function (link) { return link.source.y })
    .attr('x2', function (link) { return link.target.x })
    .attr('y2', function (link) { return link.target.y })
})

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

Any ideas?


Solution

  • The new d3.forceRadial()

    What you need is d3.forceRadial, introduced in D3 v4.11. According to the API, d3.forceRadial(radius[, x][, y]) will...

    Create a new positioning force towards a circle of the specified radius centered at ⟨x,y⟩.

    In your case, I'm using level to set the radius:

    .force('radial', d3.forceRadial(function(d) {
        return d.level * 50
    }, width / 2, height / 2))
    

    Things are easier when you have only nodes. However, since you have links in that force, you'll have to tweak the link force until you get the desired result.

    Here is your code with d3.forceRadial:

    var nodes = [{
      id: "pusat",
      group: 0,
      label: "pusat",
      level: 0
    }, {
      id: "dki",
      group: 1,
      label: "dki",
      level: 1
    }, {
      id: "jaksel",
      group: 1,
      label: "jaksel",
      level: 3
    }, {
      id: "jakpus",
      group: 1,
      label: "jakpus",
      level: 3
    }, {
      id: "jabar",
      group: 2,
      label: "jabar",
      level: 1
    }, {
      id: "sumedang",
      group: 2,
      label: "sumedang",
      level: 3
    }, {
      id: "bekasi",
      group: 2,
      label: "bekasi",
      level: 3
    }, {
      id: "bandung",
      group: 2,
      label: "bandung",
      level: 3
    }, {
      id: "jatim",
      group: 3,
      label: "jatim",
      level: 1
    }, {
      id: "malang",
      group: 3,
      label: "malang",
      level: 3
    }, {
      id: "lamongan",
      group: 3,
      label: "lamongan",
      level: 3
    }, {
      id: "diy",
      group: 4,
      label: "diy",
      level: 1
    }, {
      id: "sleman",
      group: 4,
      label: "sleman",
      level: 3
    }, {
      id: "jogja",
      group: 4,
      label: "jogja",
      level: 3
    }, {
      id: "bali",
      group: 5,
      label: "bali",
      level: 1
    }, {
      id: "bali1",
      group: 5,
      label: "bali1",
      level: 3
    }, {
      id: "bali2",
      group: 5,
      label: "bali2",
      level: 3
    }, {
      id: "ntt",
      group: 6,
      label: "ntt",
      level: 1
    }, {
      id: "ntt1",
      group: 6,
      label: "ntt1",
      level: 3
    }, {
      id: "ntt2",
      group: 6,
      label: "ntt2",
      level: 3
    }]
    
    var links = [{
        target: "pusat",
        source: "dki",
        strength: 0.2,
        value: 1
      }, {
        target: "pusat",
        source: "jabar",
        strength: 0.2,
        value: 3
      }, {
        target: "pusat",
        source: "jatim",
        strength: 0.2,
        value: 6
      }, {
        target: "pusat",
        source: "diy",
        strength: 0.2,
        value: 1
      }, {
        target: "pusat",
        source: "bali",
        strength: 0.2,
        value: 1
      }, {
        target: "pusat",
        source: "ntt",
        strength: 0.2,
        value: 1
      },
    
      //{ target: "pusat", source: "malang" , strength: 0.2, value:3 },
      //{ target: "pusat", source: "lamongan" , strength: 0.2, value:6 },
    
      {
        target: "dki",
        source: "jaksel",
        strength: 0.7,
        value: 2
      }, {
        target: "dki",
        source: "jakpus",
        strength: 0.7,
        value: 3
      }, {
        target: "jabar",
        source: "sumedang",
        strength: 0.7,
        value: 0.5
      }, {
        target: "jabar",
        source: "bekasi",
        strength: 0.7,
        value: 2
      }, {
        target: "jabar",
        source: "bandung",
        strength: 0.7,
        value: 2
      }, {
        target: "jatim",
        source: "malang",
        strength: 0.7,
        value: 3
      }, {
        target: "jatim",
        source: "lamongan",
        strength: 0.7,
        value: 1
      }, {
        target: "diy",
        source: "sleman",
        strength: 0.7,
        value: 3
      }, {
        target: "diy",
        source: "jogja",
        strength: 0.7,
        value: 1
      }, {
        target: "bali",
        source: "bali1",
        strength: 0.7,
        value: 1
      }, {
        target: "bali",
        source: "bali2",
        strength: 0.7,
        value: 1
      }, {
        target: "ntt",
        source: "ntt1",
        strength: 0.7,
        value: 1
      }, {
        target: "ntt",
        source: "ntt2",
        strength: 0.7,
        value: 1
      }
    ]
    
    function getNeighbors(node) {
      return links.reduce(function(neighbors, link) {
        if (link.target.id === node.id) {
          neighbors.push(link.source.id)
        } else if (link.source.id === node.id) {
          neighbors.push(link.target.id)
        }
        return neighbors
      }, [node.id])
    }
    
    function isNeighborLink(node, link) {
      return link.target.id === node.id || link.source.id === node.id
    }
    
    
    function getNodeColor(node, neighbors) {
      if (Array.isArray(neighbors) && neighbors.indexOf(node.id) > -1) {
        return node.level === 1 ? 'blue' : 'green'
      }
    
      return node.level === 1 ? 'red' : 'gray'
    }
    
    
    function getLinkColor(node, link) {
      return isNeighborLink(node, link) ? 'green' : '#E5E5E5'
    }
    
    function getTextColor(node, neighbors) {
      return Array.isArray(neighbors) && neighbors.indexOf(node.id) > -1 ? 'green' : 'black'
    }
    
    var width = window.innerWidth
    var height = window.innerHeight
    
    var svg = d3.select('svg')
    svg.attr('width', width).attr('height', height);
    
    var circles = svg.selectAll(null)
      .data([80,125])
      .enter()
      .append("circle")
      .attr("cx", width/2)
      .attr("cy", height/2)
      .attr("r", d=>d)
      .style("fill", "none")
      .style("stroke", "#ccc");
    
    // simulation setup with all forces
    var linkForce = d3
      .forceLink()
      .id(function(link) {
        return link.id 
      });
    
    var simulation = d3
      .forceSimulation()
      .force('link', linkForce)
      .force('charge', d3.forceManyBody().strength(-120))
      .force('radial', d3.forceRadial(function(d) {
        return d.level * 50
      }, width / 2, height / 2))
      .force('center', d3.forceCenter(width / 2, height / 2))
    
    var dragDrop = d3.drag().on('start', function(node) {
      node.fx = node.x
      node.fy = node.y
    }).on('drag', function(node) {
      simulation.alphaTarget(0.7).restart()
      node.fx = d3.event.x
      node.fy = d3.event.y
    }).on('end', function(node) {
      if (!d3.event.active) {
        simulation.alphaTarget(0)
      }
      node.fx = null
      node.fy = null
    })
    
    function selectNode(selectedNode) {
      var neighbors = getNeighbors(selectedNode)
    
      // we modify the styles to highlight selected nodes
      nodeElements.attr('fill', function(node) {
        return getNodeColor(node, neighbors) 
      })
      textElements.attr('fill', function(node) {
        return getTextColor(node, neighbors)
      })
      linkElements.attr('stroke', function(link) {
        return getLinkColor(selectedNode, link)
      })
    }
    
    var linkElements = svg.append("g")
      .attr("class", "links")
      .selectAll("line")
      .data(links)
      .enter().append("line")
      .attr("stroke-width", function(link) {
        return link.value;
      })
      .attr("stroke", "rgba(50, 50, 50, 0.2)")
    
    var nodeElements = svg.append("g")
      .attr("class", "nodes")
      .selectAll("circle")
      .data(nodes)
      .enter().append("circle")
      .attr("r", 10)
      .attr("fill", getNodeColor)
      .call(dragDrop)
      .on('click', selectNode)
    
    var textElements = svg.append("g")
      .attr("class", "texts")
      .selectAll("text")
      .data(nodes)
      .enter().append("text")
      .text(function(node) {
        return node.label
      })
      .attr("font-size", 15)
      .attr("dx", 15)
      .attr("dy", 4)
    
    simulation.nodes(nodes).on('tick', () => {
      nodeElements
        .attr('cx', function(node) {
          return node.x
        })
        .attr('cy', function(node) {
          return node.y
        })
      textElements
        .attr('x', function(node) {
          return node.x
        })
        .attr('y', function(node) {
          return node.y
        })
      linkElements
        .attr('x1', function(link) {
          return link.source.x
        })
        .attr('y1', function(link) {
          return link.source.y
        })
        .attr('x2', function(link) {
          return link.target.x
        })
        .attr('y2', function(link) {
          return link.target.y
        })
    })
    
    simulation.force("link").links(links)
    <svg width="960" height="600">
    </svg>
    
    <script src="https://d3js.org/d3.v4.min.js"></script>

    As I said, because you have links, things are a bit more complicated. Look how d3.forceRadial creates a nice radial pattern if you had only nodes (here, together with d3.forceCollide):

    var nodes = [{
      id: "pusat",
      group: 0,
      label: "pusat",
      level: 0
    }, {
      id: "dki",
      group: 1,
      label: "dki",
      level: 1
    }, {
      id: "jaksel",
      group: 1,
      label: "jaksel",
      level: 3
    }, {
      id: "jakpus",
      group: 1,
      label: "jakpus",
      level: 3
    }, {
      id: "jabar",
      group: 2,
      label: "jabar",
      level: 1
    }, {
      id: "sumedang",
      group: 2,
      label: "sumedang",
      level: 3
    }, {
      id: "bekasi",
      group: 2,
      label: "bekasi",
      level: 3
    }, {
      id: "bandung",
      group: 2,
      label: "bandung",
      level: 3
    }, {
      id: "jatim",
      group: 3,
      label: "jatim",
      level: 1
    }, {
      id: "malang",
      group: 3,
      label: "malang",
      level: 3
    }, {
      id: "lamongan",
      group: 3,
      label: "lamongan",
      level: 3
    }, {
      id: "diy",
      group: 4,
      label: "diy",
      level: 1
    }, {
      id: "sleman",
      group: 4,
      label: "sleman",
      level: 3
    }, {
      id: "jogja",
      group: 4,
      label: "jogja",
      level: 3
    }, {
      id: "bali",
      group: 5,
      label: "bali",
      level: 1
    }, {
      id: "bali1",
      group: 5,
      label: "bali1",
      level: 3
    }, {
      id: "bali2",
      group: 5,
      label: "bali2",
      level: 3
    }, {
      id: "ntt",
      group: 6,
      label: "ntt",
      level: 1
    }, {
      id: "ntt1",
      group: 6,
      label: "ntt1",
      level: 3
    }, {
      id: "ntt2",
      group: 6,
      label: "ntt2",
      level: 3
    }]
    
    var links = [{
        target: "pusat",
        source: "dki",
        strength: 0.2,
        value: 1
      }, {
        target: "pusat",
        source: "jabar",
        strength: 0.2,
        value: 3
      }, {
        target: "pusat",
        source: "jatim",
        strength: 0.2,
        value: 6
      }, {
        target: "pusat",
        source: "diy",
        strength: 0.2,
        value: 1
      }, {
        target: "pusat",
        source: "bali",
        strength: 0.2,
        value: 1
      }, {
        target: "pusat",
        source: "ntt",
        strength: 0.2,
        value: 1
      },
    
      //{ target: "pusat", source: "malang" , strength: 0.2, value:3 },
      //{ target: "pusat", source: "lamongan" , strength: 0.2, value:6 },
    
      {
        target: "dki",
        source: "jaksel",
        strength: 0.7,
        value: 2
      }, {
        target: "dki",
        source: "jakpus",
        strength: 0.7,
        value: 3
      }, {
        target: "jabar",
        source: "sumedang",
        strength: 0.7,
        value: 0.5
      }, {
        target: "jabar",
        source: "bekasi",
        strength: 0.7,
        value: 2
      }, {
        target: "jabar",
        source: "bandung",
        strength: 0.7,
        value: 2
      }, {
        target: "jatim",
        source: "malang",
        strength: 0.7,
        value: 3
      }, {
        target: "jatim",
        source: "lamongan",
        strength: 0.7,
        value: 1
      }, {
        target: "diy",
        source: "sleman",
        strength: 0.7,
        value: 3
      }, {
        target: "diy",
        source: "jogja",
        strength: 0.7,
        value: 1
      }, {
        target: "bali",
        source: "bali1",
        strength: 0.7,
        value: 1
      }, {
        target: "bali",
        source: "bali2",
        strength: 0.7,
        value: 1
      }, {
        target: "ntt",
        source: "ntt1",
        strength: 0.7,
        value: 1
      }, {
        target: "ntt",
        source: "ntt2",
        strength: 0.7,
        value: 1
      }
    ]
    
    function getNeighbors(node) {
      return links.reduce(function(neighbors, link) {
        if (link.target.id === node.id) {
          neighbors.push(link.source.id)
        } else if (link.source.id === node.id) {
          neighbors.push(link.target.id)
        }
        return neighbors
      }, [node.id])
    }
    
    function isNeighborLink(node, link) {
      return link.target.id === node.id || link.source.id === node.id
    }
    
    
    function getNodeColor(node, neighbors) {
      if (Array.isArray(neighbors) && neighbors.indexOf(node.id) > -1) {
        return node.level === 1 ? 'blue' : 'green'
      }
    
      return node.level === 1 ? 'red' : 'gray'
    }
    
    
    function getLinkColor(node, link) {
      return isNeighborLink(node, link) ? 'green' : '#E5E5E5'
    }
    
    function getTextColor(node, neighbors) {
      return Array.isArray(neighbors) && neighbors.indexOf(node.id) > -1 ? 'green' : 'black'
    }
    
    var width = window.innerWidth
    var height = window.innerHeight
    
    var svg = d3.select('svg')
      .attr('width', width).attr('height', height)
      .append("g")
      .attr("transform", "translate(" + width / 2 + "," + height / 2 + ")")
    
    // simulation setup with all forces
    
    
    var simulation = d3.forceSimulation()
      .force('radial', d3.forceRadial(function(d) {
        return d.level * 50
      }).strength(1))
      .force('collide', d3.forceCollide().radius(35));
    
    var dragDrop = d3.drag().on('start', function(node) {
      node.fx = node.x
      node.fy = node.y
    }).on('drag', function(node) {
      simulation.alphaTarget(0.7).restart()
      node.fx = d3.event.x
      node.fy = d3.event.y
    }).on('end', function(node) {
      if (!d3.event.active) {
        simulation.alphaTarget(0)
      }
      node.fx = null
      node.fy = null
    })
    
    function selectNode(selectedNode) {
      var neighbors = getNeighbors(selectedNode)
    
      // we modify the styles to highlight selected nodes
      nodeElements.attr('fill', function(node) {
        return getNodeColor(node, neighbors)
      })
      textElements.attr('fill', function(node) {
        return getTextColor(node, neighbors)
      })
      linkElements.attr('stroke', function(link) {
        return getLinkColor(selectedNode, link)
      })
    }
    
    var linkElements = svg.append("g")
      .attr("class", "links")
      .selectAll("line")
      .data(links)
      .enter().append("line")
      .attr("stroke-width", function(link) {
        return link.value;
      })
      .attr("stroke", "rgba(50, 50, 50, 0.2)")
    
    var nodeElements = svg.append("g")
      .attr("class", "nodes")
      .selectAll("circle")
      .data(nodes)
      .enter().append("circle")
      .attr("r", 10)
      .attr("fill", getNodeColor)
      .call(dragDrop)
      .on('click', selectNode)
    
    var textElements = svg.append("g")
      .attr("class", "texts")
      .selectAll("text")
      .data(nodes)
      .enter().append("text")
      .text(function(node) {
        return node.label
      })
      .attr("font-size", 15)
      .attr("dx", 15)
      .attr("dy", 4)
    
    simulation.nodes(nodes).on('tick', () => {
      nodeElements
        .attr('cx', function(node) {
          return node.x
        })
        .attr('cy', function(node) {
          return node.y
        })
      textElements
        .attr('x', function(node) {
          return node.x
        })
        .attr('y', function(node) {
          return node.y
        })
    
    })
    <svg width="600" height="500">
    </svg>
    
    <script src="https://d3js.org/d3.v4.min.js"></script>

    PS: I set the level of the first node to 0.