javascriptd3.jsd3-force-directed

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:

composite node

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.

    //
    // 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 }))
    const color = d3.scaleOrdinal(d3.schemeCategory10)

    const svg = d3.select('#chart')
    
    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?
    // ******************************************************
    //
    // ??
    .graph {
      width: 1000px;
      height: 400px;      
    }
<script src="https://d3js.org/d3.v7.min.js" charset="utf-8"></script>

<svg ref="chart" id="chart" class="graph"></svg>


Solution

  • Making the display of composite nodes data-driven. Example:

    <!DOCTYPE html>
    
    <html>
      <head>
        <script src="https://d3js.org/d3.v7.min.js" charset="utf-8"></script>
        <style>
          .graph {
            width: 1000px;
            height: 400px;
          }
        </style>
      </head>
    
      <body>
        <svg ref="chart" id="chart" class="graph"></svg>
        <script>
          //
          // Create the force directed graph
          //
    
          const width = 1000;
          const height = 400;
    
          const node_data = Array.from({ length: 5 }, () => ({
            group: Math.floor(Math.random() * 3),
            isComposite: Math.random() > 0.5,
          }));
    
          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 }));
          const color = d3.scaleOrdinal(d3.schemeCategory10);
    
          const svg = d3.select('#chart');
    
          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('g');
    
          node
            .filter((d) => !d.isComposite)
            .append('circle')
            .attr('r', 16)
            .attr('fill', (d) => color(d.group));
    
          const composite = node
            .filter((d) => d.isComposite)
            .append('g')
            .attr('class', 'composite');
    
          composite
            .append('rect')
            .attr('class', 'node')
            .attr('rx', '15')
            .attr('width', () => 200)
            .attr('height', () => 25)
            .attr('fill', '#dceed3');
    
          composite
            .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, i) => {
              return 'name ' + i;
            });
    
          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
              .selectAll('circle')
              .attr('cx', (d) => d.x)
              .attr('cy', (d) => d.y);
            node
              .selectAll('.composite')
              .attr(
                'transform',
                (d) => 'translate(' + (d.x - 100) + ',' + 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;
          }
        </script>
      </body>
    </html>