score:2

Accepted answer

You can try that approach, or you can just create the inner force in a group already translated by the outer one:

var innerLinkContainer = outerNodes.filter((_, i) => i)
  .append("g").attr("class", "innerLinkContainer");
var innerNodeContainer = outerNodes.filter((_, i) => i)
  .append("g").attr("class", "innerNodeContainer");

Here is your code with that change only:

<!DOCTYPE html>
<html lang="en">

<head>
  <meta charset="UTF-8">
  <title>D3v6 Pack</title>
  <script src="https://d3js.org/d3.v6.min.js"></script>
</head>

<style>
  body {
    background-color: #e6e7ee;
  }
  
  circle {
    fill: whitesmoke;
    stroke: black;
    stroke-width: 1px;
  }
</style>

<body>
  <script>
    var svg = d3.select("body").append("svg")
      .attr("width", window.innerWidth)
      .attr("height", window.innerHeight)
      .attr("class", "svg")
      .call(d3.zoom().on("zoom", function(event) {
        svg.attr("transform", event.transform)
      }))
      .append("g")

    var outerLinkContainer = svg.append("g").attr("class", "outerLinkContainer")
    var outerNodeContainer = svg.append("g").attr("class", "outerNodeContainer")



    ////////////////////////
    // outer force layout

    var outerData = {
      "nodes": [{
          "id": "A"
        },
        {
          "id": "B"
        },
      ],
      "links": [{
        "source": "B",
        "target": "A"
      }, ]
    }

    var outerLayout = d3.forceSimulation()
      .force("link", d3.forceLink().id(function(d) {
        return d.id;
      }).distance(200))
      .force("charge", d3.forceManyBody().strength(-650))
      .force("center", d3.forceCenter(window.innerWidth / 2, window.innerHeight / 2))
      .force("collision", d3.forceCollide().radius(50))

    var outerLinks = outerLinkContainer.selectAll(".link")
      .data(outerData.links)
      .join("line")
      .attr("class", "link")
      .style("stroke", "black")
      .style("opacity", 0.2)

    var outerNodes = outerNodeContainer.selectAll("g.outer")
      .data(outerData.nodes, function(d) {
        return d.id;
      })
      .enter()
      .append("g")
      .attr("class", "outer")
      .attr("id", function(d) {
        return d.id;
      })
      .call(d3.drag()
        .on("start", dragStarted)
        .on("drag", dragged)
        .on("end", dragEnded)
      )

    outerNodes
      .append("circle")
      .attr("r", 40)

    outerNodes.selectAll("text")
      .data(d => [d])
      .join("text")
      .attr("dominant-baseline", "central")
      .attr("text-anchor", "middle")
      .attr("id", function(d) {
        return "text" + d.id
      })
      .text(function(d) {
        return d.id
      })

    outerLayout
      .nodes(outerData.nodes)
      .on("tick", outerTick)

    outerLayout
      .force("link")
      .links(outerData.links)

    ////////////////////////
    // inner force layouts

    var innerLinkContainer = outerNodes.filter((_, i) => i)
      .append("g").attr("class", "innerLinkContainer");
    var innerNodeContainer = outerNodes.filter((_, i) => i)
      .append("g").attr("class", "innerNodeContainer");

    var innerAdata = {
      "nodes": [{
          "id": "B1"
        },
        {
          "id": "B2"
        },
        {
          "id": "B3"
        },
      ],
      "links": [{
          "source": "B1",
          "target": "B2"
        },
        {
          "source": "B2",
          "target": "B3"
        },
        {
          "source": "B3",
          "target": "B1"
        }
      ]
    }


    var innerLayout = d3.forceSimulation()
      .force("link", d3.forceLink().id(function(d) {
        return d.id;
      }).distance(50))
      .force("charge", d3.forceManyBody().strength(-50))
      .force("collision", d3.forceCollide().radius(6))

    var innerLinks = innerLinkContainer.selectAll(".link")
      .data(innerAdata.links)
      .join("line")
      .attr("class", "link")
      .style("stroke", "black")
      .style("opacity", 0.5)

    var innerNodes = innerNodeContainer.selectAll("g.inner")
      .data(innerAdata.nodes, function(d) {
        return d.id;
      })
      .enter()
      .append("g")
      .attr("class", "inner")
      .attr("id", function(d) {
        return d.id;
      })
      .call(d3.drag()
        .on("start", dragStarted)
        .on("drag", dragged)
        .on("end", dragEnded)
      )

    innerNodes
      .append("circle")
      .style("fill", "orange")
      .style("stroke", "blue")
      .attr("r", 6);

    innerLayout
      .nodes(innerAdata.nodes)
      .on("tick", innerAtick)

    innerLayout
      .force("link")
      .links(innerAdata.links)

    function outerTick() {
      outerLinks
        .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;
        });

      outerNodes.attr("transform", function(d) {
        return "translate(" + d.x + "," + d.y + ")";
      });
    }

    function innerAtick() {
      innerLinks
        .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;
        });

      innerNodes.attr("transform", function(d) {
        return "translate(" + d.x + "," + d.y + ")";
      });
    }


    function dragStarted(event, d) {
      if (!event.active)

        outerLayout.alphaTarget(0.3).restart();
      innerLayout.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)

        outerLayout.alphaTarget(0);
      innerLayout.alphaTarget(0);

      d.fx = undefined;
      d.fy = undefined;
    }
  </script>
</body>

</html>

This will not make the inner force bounded to the outer one (as you can see by dragging an inner node), but I'm not sure if this is the behaviour you want.

score:3

The approach in the snippet below is specific to your example (and relates to the previous question) and relies on these things:

Firstly, initializing the force simulation with the outer nodes...

... means that you can always refer back to the data object to get the outer node coordinates within the svg. So for the g containing outerNodes the outerData contains the x and y you are looking for, after the outerLayout has computed those positions e.g.:

{
  "id": "A",
  "index": 0,
  "x": 409.28298494419124,
  "y": 321.93152757995455,
  "vy": -0.0005382622043197348,
  "vx": 0.0006924019130575043
}

Note that this won't work generally because for nested groups, the x and y are relative to their parent group. The outer node coordinates are relative to the svg so they are the coordinates you are looking for.

Secondly, to solve for the inner force not being bounded by the outer force...

... use d3.forceRadial. The working examples are a bit rare, but you can look under the hood of this one by @GerardoFurtado which I found useful to provide this answer. There's also this block and this other answer.

For the inner force I am using this code:

const innerForce = d3.forceSimulation(subgraphNodes)
  .force("r", d3.forceRadial(outerRadius - outerMargin).strength(1.1))
  .force("charge", d3.forceCollide().radius(innerRadius))
  .force("link", d3.forceLink().id(d => d.id).distance(outerRadius - outerMargin));

Where:

  • subgraphNodes is like your innerData (but I have combined the data objects in the example)
  • outerRadius is the radius of the outer node and outerMargin is some padding to keep the inner nodes inside of the outer node. 1.1 seemed a reasonably strength parameter to keep the inner nodes bounded within their outer node (but notice you can still drag them outside)
  • use innerRadius for d3.forceCollide
  • use the same outerRadius - outerMargin for the distance on d3.forceLink

So whilst you can drag an inner node out of the outer node, it will spring back to a position on the inner radius of the outer node (defined by outerRadius - outerMargin). This seems to have a minimal effect on the layout of the outer nodes, which is helpful.

Thirdly, I am setting up an inner force layout for each outer node...

(at least in the example data I used, this is how it works):

graph.outer.nodes.forEach(outerNode => {
  const parent = svg.select(`g.outer#${outerNode.id}`)
  const subgraphNodes = graph[`inner${outerNode.id}`].nodes;
  const subgraphLinks = graph[`inner${outerNode.id}`].links;

  const innerLinks = parent.selectAll("g.inner")
    .data(subgraphLinks)
    .join("line")
    .attr("class", "link")
    .style("stroke", "black"); 

  const innerNodes = parent.selectAll("g.inner")
  // ...
  
});

By nesting the inner nodes in their parent (the outer node) you get the outcome Gerardo refers to in his answer:

the inner force in a group already translated by the outer one

For each inner layout created, I am tracking them in an array so we can refer to the nodes, links, parent and force later on in the ticked and various drag functions:

childSets.push({
  force: innerForce,
  parent: outerNode.id,
  nodes: innerNodes,
  links: innerLinks
});

Fourth, in the ticked function:

function ticked() {
  outerLinks
    .attr("x1", d => d.source.x)
    .attr("y1", d => d.source.y)
    .attr("x2", d => d.target.x)
    .attr("y2", d => d.target.y);

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

  childSets.forEach(set => {
    
    const parent = graph.outer.nodes.find(n => n.id === set.parent);

    set.nodes.attr("transform", d => `translate(${parent.x + d.x},${parent.y + d.y})`);

    set.links
      .attr("x1", d => d.source.x + parent.x)
      .attr("y1", d => d.source.y + parent.y)
      .attr("x2", d => d.target.x + parent.x)
      .attr("y2", d => d.target.y + parent.y);

  });
}

The treatment of outerNodes and outerLinks is the same as your code.

For the inner force layouts, I iterate the array I created for each forceSimulation initialization and set a transform on the nodes where the translate refers both the inner node coordinates and those of the parent node - the parent coordinates are from:

const parent = graph.outer.nodes.find(n => n.id === set.parent);

Then we can refer to parent.x and parent.y where we need to.

Fifth, update the drag handlers so that each inner layout gets handled

E.g.

function dragStarted(event, d) {
  if (!event.active) {
    outerForce.alphaTarget(0.3).restart();
    childSets.forEach(set => set.force.alphaTarget(0.3).restart());  
  }
  d.fx = d.x;
  d.fy = d.y;
}

Finally: putting it all together:

const width = 600;
const height = 400;
const outerRadius = 40;
const outerMargin = 16;
const innerRadius = 6;
const outerLinkDistance = 100;
let childSets = [];

const graph = {
  outer: {
    nodes: [
      {id: "A"},
      {id: "B"},
      {id: "C"},
      {id: "D"},
      {id: "E"}, 
      {id: "F"}, 
      {id: "G"}, 
    ],
    links: [
      {source: "A", target: "B"},
      {source: "B", target: "E"},
      {source: "C", target: "F"},
      {source: "C", target: "A"},
      {source: "E", target: "A"},
      {source: "D", target: "C"},
      {source: "D", target: "F"},
      {source: "A", target: "F"},
      {source: "B", target: "G"},
      {source: "G", target: "C"},
    ]  
  },
  innerA: {
    nodes: [
      {id: "A1", parent: "A"}, 
      {id: "A2", parent: "A"}, 
      {id: "A3", parent: "A"},
    ],
    links: [
      {source: "A1", target: "A2"},
      {source: "A2", target: "A3"},
      {source: "A3", target: "A1"},
    ]
  },
  innerB: {
    nodes: [
      {id: "B1", parent: "B"}, 
      {id: "B2", parent: "B"}, 
      {id: "B3", parent: "B"}, 
      {id: "B4", parent: "B"}, 
      {id: "B5", parent: "B"},
    ],
    links: [
      {source: "B1", target: "B2"},
      {source: "B2", target: "B3"},
      {source: "B3", target: "B4"},
      {source: "B4", target: "B5"},
      {source: "B5", target: "B1"},
      {source: "B1", target: "B3"},
      {source: "B3", target: "B5"},
    ]    
  },
  innerC: {
    nodes: [
      {id: "C1", parent: "C"}, 
      {id: "C2", parent: "C"},
    ],
    links: [
      {source: "C1", target: "C2"}
    ]    
  },
  innerD: {
    nodes: [
      {id: "D1", parent: "D"}, 
      {id: "D2", parent: "D"},
    ],
    links: []    
  },
  innerE: {
    nodes: [
      {id: "E1", parent: "E"}, 
      {id: "E2", parent: "E"}, 
      {id: "E3", parent: "E"},
    ],
    links: [
      {source: "E1", target: "E2"}, 
      {source: "E2", target: "E3"}, 
      {source: "E3", target: "E1"}, 
    ]    
  },
  innerF: {
    nodes: [
      {id: "F1", parent: "F"}, 
      {id: "F2", parent: "F"}, 
      {id: "F3", parent: "F"}, 
      {id: "F4", parent: "F"}, 
    ],
    links: [
      {source: "F1", target: "F2"}, 
      {source: "F1", target: "F3"}, 
      {source: "F3", target: "F4"}, 
    ]    
  },
  innerG: {
    nodes: [
      {id: "G1", parent: "G"}
    ],
    links: []    
  }
}

const svg = d3.select("body")
  .append("svg")
  .attr("width", width)
  .attr("height", height);

const outerLinkG = svg.append("g")
  .attr("class", "outerlinks");

const outerNodeG = svg.append("g")
  .attr("class", "outernodes");

const outerForce = d3.forceSimulation()
  .force("center", d3.forceCenter(width / 2, height / 2))
  .force("charge", d3.forceManyBody().strength(-500))
  .force("link", d3.forceLink().id(d => d.id).distance(outerLinkDistance));

const outerLinks = outerLinkG.selectAll(".link")
  .data(graph.outer.links)
  .join("line")
  .attr("class", "link")
  .style("stroke", "black")
  .style("opacity", 0.2);

const outerNodes = outerNodeG.selectAll("g.outer")
  .data(graph.outer.nodes, d => d.id)
  .join("g")
  .attr("class", "outer")
  .attr("id", d => d.id)
  .append("circle")
  .style("fill", "pink")
  .style("stroke", "blue")
  .attr("r", outerRadius)
  .call(d3.drag()
    .on("start", dragStarted)
    .on("drag", dragged)
    .on("end", dragEnded)
  );

outerForce
  .nodes(graph.outer.nodes)
  .on("tick", ticked);

outerForce
  .force("link")
  .links(graph.outer.links);

graph.outer.nodes.forEach(outerNode => {
  const parent = svg.select(`g.outer#${outerNode.id}`)
  const subgraphNodes = graph[`inner${outerNode.id}`].nodes;
  const subgraphLinks = graph[`inner${outerNode.id}`].links;

  const innerLinks = parent.selectAll("g.inner")
    .data(subgraphLinks)
    .join("line")
    .attr("class", "link")
    .style("stroke", "black");
  
  const innerNodes = parent.selectAll("g.inner")
    .data(subgraphNodes, d=> d.id)
    .join("g")
    .attr("class", "inner")
    .attr("id", d => d.id)
    .append("circle")
    .attr("r", innerRadius)
    .style("fill", "orange")
    .style("stroke", "blue")
    .call(d3.drag()
      .on("start", dragStarted)
      .on("drag", dragged)
      .on("end", dragEnded)
    );

  // https://gerardofurtado.com/vr/vr.html
  const innerForce = d3.forceSimulation(subgraphNodes)
    .force("r", d3.forceRadial(outerRadius - outerMargin).strength(1.1))
    .force("charge", d3.forceCollide().radius(innerRadius))
    .force("link", d3.forceLink().id(d => d.id).distance(outerRadius - outerMargin));

  innerForce
    .on("tick", ticked);
  
  innerForce
    .force("link")
    .links(subgraphLinks);

  childSets.push({
    force: innerForce,
    parent: outerNode.id,
    nodes: innerNodes,
    links: innerLinks
  });

});

function ticked() {
  outerLinks
    .attr("x1", d => d.source.x)
    .attr("y1", d => d.source.y)
    .attr("x2", d => d.target.x)
    .attr("y2", d => d.target.y);

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

  childSets.forEach(set => {
    
    const parent = graph.outer.nodes.find(n => n.id === set.parent);

    set.nodes.attr("transform", d => `translate(${parent.x + d.x},${parent.y + d.y})`);

    set.links
      .attr("x1", d => d.source.x + parent.x)
      .attr("y1", d => d.source.y + parent.y)
      .attr("x2", d => d.target.x + parent.x)
      .attr("y2", d => d.target.y + parent.y);

  });
}

function dragStarted(event, d) {
  if (!event.active) {
    outerForce.alphaTarget(0.3).restart();
    childSets.forEach(set => set.force.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) {
    outerForce.alphaTarget(0);
    childSets.forEach(set => set.force.alphaTarget(0));
  }
  d.fx = undefined;
  d.fy = undefined;
}
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/6.5.0/d3.min.js"></script>

What seems to be not feasible with this approach is creating links between inner nodes that have different outer nodes. There's also an area for improvement regarding the 'clumpy' nature of the inner nodes within the outer node.


Related Query