2

Since D3v4 its possible to define .forceCenter or .forceX and .forceY, instead of .size in D3v3.

var force = d3.forceSimulation()
            .force("center", d3.forceCenter(width/2, heigth/2))

or

var force = d3.forceSimulation()
              .force("x", d3.forceX(500))
              .force("y", d3.forceY(500))

So it is possible to set coords, then it must be also possible to use the x and y value of another DOM element as an reference. A proper use case would be to visually combine d3 force graphs. The question would be how to use an DOM element as a reference, as it seems rocket science to get those DOM element.x and element.y values.

  • .getBBox() returns wrong coords,
  • d3.transform was removed by API,
  • .attr("x") returns null

The goal is to get the x and y position from outerNode A and use those values for .force("x") and .force("y"). Those values for sure can be updated in the tick function with the help of an own function but for now I am struggling to even get those coords.

 var innerLayout = d3.forceSimulation()
        .force("link", d3.forceLink().id(function (d) {
            return d.id;
        }).distance(50))
        .force("charge", d3.forceManyBody().strength(-50))
        .force("x", d3.forceX(870)) //## <--- DOM element.x reference
        .force("y", d3.forceY(370)) //## <--- DOM element.y reference
        .force("collision", d3.forceCollide().radius(6))

enter image description here

<!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")

        var innerLinkContainer = svg.append("g").attr("class", "innerLinkContainer")
        var innerNodeContainer = svg.append("g").attr("class", "innerNodeContainer")

        ////////////////////////
        // 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 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("x", d3.forceX(250)) //## <--- DOM element.x reference
            .force("y", d3.forceY(250)) //## <--- DOM element.y reference
            .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>
Gerardo Furtado
  • 100,839
  • 9
  • 121
  • 171
ICoded
  • 319
  • 3
  • 18

2 Answers2

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.

Robin Mackenzie
  • 18,801
  • 7
  • 38
  • 56
2

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.

Gerardo Furtado
  • 100,839
  • 9
  • 121
  • 171
  • I got your point. Unfortunately as you mentioned it doesn´t fulfil my wishes. As inner node dragging destroys the layout. It would do the job if I deny the drag function for the inner nodes or create a special condition for the inner nodes, like if dragged store start position and return the node to this position if dragended. Both ideas are not completely satisfying. Its really a problem in D3. – ICoded Apr 17 '21 at 14:47
  • I performed slightly changes to adapt your idea. Since you are filtering the outerNodes.filter(_, i) it must be also possible to add an condition which should sort the innerNodes and attach them to the correct outerNodes. Like a parent attribute etc. – ICoded Apr 19 '21 at 06:38