How can a composite node be added into a d3 force directed graph?

I create a force directed layout graph. I then create a composite node. The composite node looks like:

image

I would like to create an edge between one of the circle nodes to the composite node. I would like the composite node to participate in the force directed layout.

How can I do this?

I figure I need to merge nodes and labelNodes together and pass them into .forceSimulation(nodes). However, I am not sure how to do that.

I am sure this is straightforward, but I am getting lost in the documentation and am missing some key concept that would point me to the solution.

If it helps, there is a CodePen using the code below – composite node in force directed graph – simple

What I have done so far is:

<html>
  <head>
    <script src="https://d3js.org/d3.v7.min.js" charset="utf-8"></script>
  </head>

  <style>
    .graph {
      width: 1000px;
      height: 400px;
    }
  </style>

  <script type="module">
    //
    // Create the force directed graph
    //

    const width = 1000
    const height = 400

    const node_data = Array.from({ length: 5 }, () => ({
      group: Math.floor(Math.random() * 3),
    }))

    const edge_data = Array.from({ length: 10 }, () => ({
      source: Math.floor(Math.random() * 5),
      target: Math.floor(Math.random() * 5),
      value: Math.floor(Math.random() * 10) + 1,
    }))

    const links = edge_data.map((d) => ({ ...d }))
    const nodes = node_data.map((d, index) => ({ id: index, ...d }))

    console.log(`🚀 ~ nodes:`, nodes)
    console.log(`🚀 ~ links:`, links)

    const color = d3.scaleOrdinal(d3.schemeCategory10)

    const svg = d3.select('#chart')
    console.log(`🚀 ~ svg:`, svg)

    const simulation = d3
      .forceSimulation(nodes)
      .force(
        'link',
        d3
          .forceLink(links)
          .id((d) => d.id)
          .distance((d) => 100)
      )
      .force('charge', d3.forceManyBody())
      .force('center', d3.forceCenter(width / 2, height / 2))
      .on('tick', ticked)

    const link = svg
      .append('g')
      .attr('stroke', '#999')
      .attr('stroke-opacity', 0.6)
      .selectAll()
      .data(links)
      .join('line')
      .attr('stroke-width', (d) => Math.sqrt(d.value))

    const node = svg
      .append('g')
      .attr('stroke', '#fff')
      .attr('stroke-width', 1.5)
      .selectAll()
      .data(nodes)
      .join('circle')
      .attr('r', 16)
      .attr('fill', (d) => color(d.group))

    node.append('title').text((d) => `hello ${d.id}`)

    function ticked() {
      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)
    }

    node.call(d3.drag().on('start', dragstarted).on('drag', dragged).on('end', dragended))

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

    function dragged(event) {
      event.subject.fx = event.x
      event.subject.fy = event.y
    }

    function dragended(event) {
      if (!event.active) simulation.alphaTarget(0)
      event.subject.fx = null
      event.subject.fy = null
    }

    //
    // Create the composite node
    //

    const labelNodes = [
      {
        id: 0,
        name: 'A title',
      },
    ]

    let composite = svg
      .append('g')
      .attr('id', 'composite')
      .selectAll('g')
      .data(labelNodes, (d) => d.id)

    const g = composite.enter()

    const rectangularNode = g
      .append('rect')
      .attr('class', 'node')
      .attr('rx', '15')
      .attr('x', (d) => 0)
      .attr('y', (d) => 0)
      .attr('width', () => 200)
      .attr('height', () => 25)
      .attr('fill', '#dceed3')

    var outerNodebbox = rectangularNode.node().getBBox()

    const label = g
      .append('text')
      .attr('class', 'name')
      .attr('ref', 'name')
      .attr('id', 'name')
      .attr('dominant-baseline', 'middle')
      .attr('font-size', '10px')
      .attr('x', () => 13)
      .attr('y', () => 13)
      .text((d) => {
        return d['name']
      })

    composite = g.merge(composite)

    // ******************************************************
    // What goes here to add the composite node to the graph?
    // ******************************************************
    //
    // ??
  </script>
  <svg ref="chart" id="chart" class="graph"></svg>
</html>

Is your expectation that the node’s pill shape will be considered in the calculation of the forces? If so, then you’ll have to implement your own force or look into other physics libraries (like matter.js) since out of the box d3-force only supports points.

@mootari

If the composite node were represented by a single point, that would be fine. Would a custom force still be needed? Is what I want to do possible?

Here’s an example where the nodes are somewhat more complicated than just a single circle:

In principle, the nodes can be anything you can render in SVG. In the linked example, the icons fit nicely into a circle, so that it’s easy to use a simple collide force (which works by defining a collision radius) to keep the icons from overlapping. I think that mootari’s point is that your composite node might not work very well with this technique, since it’s so much wider than it is tall.

@mcmcclur

The problem I am having is with the “in principle” vs “in practice” aspect of this. I am sure that what I need to accomplish is possible “in principle,” but I am finding it difficult to put those principles into practice.

I looked at the “Force graph with icons,” but I haven’t been able to put to use anything found in there. For example, I tried changing

    const node = svg
      .append('g')
      .attr('stroke', '#fff')
      .attr('stroke-width', 1.5)
      .selectAll()
      .data(nodes)
      .join('circle')
      .attr('r', 16)
      .attr('fill', (d) => color(d.group))

to:

const node = svg
  .append("g")
  .attr("stroke", "#fff")
  .attr("stroke-width", 1.5)
  .selectAll()
  .data(nodes)
  .join((enter) => {
    const g = enter
      .append("circle")
      .attr("r", 16)
      .attr("fill", (d) => color(d.group))
      .attr("stroke", "black")
      .attr("fill-opacity", 0.4)
      .attr("stroke-opacity", 0.2)
      .attr("stroke-width", 40);
  });

It seems like these two statements should be equivalent, but the second one does not work. The circles are not drawn in the correct locations. Demonstration: https://codepen.io/ericg_off/pen/zYbXxrK

What fundamental D3 concepts am I missing?

I’d recommend that you don’t update cx or cy directly. Instead, add a group for each node as container for whatever element you want to represent the node with, then set

.attr("transform", d => `translate(${d.x},${d.y})`)

on each update. That will allow you to append your composite element to the group of the corresponding node.