Skip to content

D3v6 nested graph – nested join()?

I want to visualize the “children” insight each node. I guess the D3v6 .join() function can be nested. Unfortunately I can´t find any example. The snippet below contains an outerGraph with 3 nodes and children as attribute. So far those children aren´t used yet.

The innerGraph instead visualize the small nodes which will be obsolete as soon as the children approach is working. Another Idea would be to work with those two graphs and create a gravity / cluster, which will be the parent.

Goal: Either utilize the children attribute or combine both graphs with the help of an cluster /gravity or even nested join(). I am appreciating any hint / tip. The visuals result should be:

enter image description here

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

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

<style>
    body {
        background-color: whitesmoke;
    }
</style>

<body>
    <script>
        var svg = d3.select("body").append("svg")
            .attr("width", window.innerWidth)
            .attr("height", window.innerHeight);

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

        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 Layouts #############
        //###############################################

        var outerGraph = {
            "nodes": [
                {
                    "id": "A",
                    "children": [
                        {
                            "id": "A1"
                        },
                        {
                            "id": "A2"
                        }
                    ]
                },
                {
                    "id": "B",
                    "children": [
                        {
                            "id": "B1"
                        },
                        {
                            "id": "B2"
                        }
                    ]
                },
                {
                    "id": "C",
                    "children": [
                        {
                            "id": "C1"
                        },
                        {
                            "id": "C2"
                        }
                    ]
                }
            ],
            "links": [
                {
                    "source": "A",
                    "target": "B"
                },
                {
                    "source": "B",
                    "target": "C"
                },
                {
                    "source": "C",
                    "target": "A"
                },
            ]
        }

        var outerLayout = d3.forceSimulation()
            .force("center", d3.forceCenter(width / 2, height / 2))
            .force("charge", d3.forceManyBody().strength(-500))
            .force("link", d3.forceLink().id(function (d) {
                return d.id;
            }).distance(200))

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

        var outerNodes = outerNodeContainer.selectAll("g.outer")
            .data(outerGraph.nodes, function (d) { return d.id; })
            .join("circle")
            .attr("class", "outer")
            .style("fill", "pink")
            .style("stroke", "blue")
            .attr("r", 40)
            .call(d3.drag()
                .on("start", dragStarted)
                .on("drag", dragged)
                .on("end", dragEnded)
            )

        outerLayout
            .nodes(outerGraph.nodes)
            .on("tick", ticked)

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

        //###############################################
        //############## inner force Layout #############
        //###############################################

        var innerGraph = {
            "nodes": [
                { "id": "A1" },
                { "id": "A2" }
            ],
            "links": [
                {
                    "source": "A1",
                    "target": "A2"
                }
            ]
        }

        var innerlayout = d3.forceSimulation()
            .force("center", d3.forceCenter(width / 2, height / 2))
            .force("charge", d3.forceManyBody().strength(-500))
            .force("link", d3.forceLink().id(function (d) {
                return d.id;
            }).distance(200))

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

        var innerNodes = innerNodeContainer.selectAll("g.inner")
            .data(innerGraph.nodes, function (d) { return d.id; })
            .join("circle")
            .style("fill", "orange")
            .style("stroke", "blue")
            .attr("r", 6)
            .attr("class", "inner")
            .attr("id", function (d) { return d.id; })
            .call(d3.drag()
                .on("start", dragStarted)
                .on("drag", dragged)
                .on("end", dragEnded)
            )

        innerlayout
            .nodes(innerGraph.nodes)
            .on("tick", ticked)

        innerlayout
            .force("link")
            .links(innerGraph.links)

        //###############################################
        //################## functons ###################
        //###############################################

        function ticked() {

            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;
                });

            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;
                });

            outerNodes.attr("transform", function (d) {
                return "translate(" + d.x + "," + d.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>

I will update the post as soon as I found a solution.

Answer

Here’s a slightly hack way to do it – I am a bit disappointed in the outcome because if you play with the outerNodes then the links between innerNodes cross over in an unattractive way.

The changes I made in your code:

  • update innerGraph so nodes have a parent property (plus add the links required to match your screenshot in the question)
  • add an additional class on outerNodes so that each outer node can be identified e.g. .outer_A, .outer_B etc
  • add an additional class on innerNodes so that each inner node can be identified e.g. .child_A1, .child_A2 etc
  • in ticked – for innerNodes return a point for the inner node so that it is sitting inside centre of it’s parent at roughly 20px from the centre on the vector between the original force simulation selected point and the parent’s centre.
  • in ticked – for innerLinks, force the source and target coordinates to update per the previous step

Those last two points are per here and here.

So it works – but only just. Vertical scrolling in the stack snippet seems to upset it a bit but it’s maybe better if you try it out on your own dev environment. I still think you could you look at other tools – maybe this one from cytoscape.js and also the webcola example I mentioned in the comments?

        var svg = d3.select("body").append("svg")
        .attr("width", window.innerWidth)
        .attr("height", window.innerHeight);

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

    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 Layouts #############
    //###############################################

    var outerGraph = {
        "nodes": [
            {
                "id": "A",
                "children": [
                    {
                        "id": "A1"
                    },
                    {
                        "id": "A2"
                    }
                ]
            },
            {
                "id": "B",
                "children": [
                    {
                        "id": "B1"
                    },
                    {
                        "id": "B2"
                    }
                ]
            },
            {
                "id": "C",
                "children": [
                    {
                        "id": "C1"
                    },
                    {
                        "id": "C2"
                    }
                ]
            }
        ],
        "links": [
            {
                "source": "A",
                "target": "B"
            },
            {
                "source": "B",
                "target": "C"
            },
            {
                "source": "C",
                "target": "A"
            },
        ]
    }

    var outerLayout = d3.forceSimulation()
        .force("center", d3.forceCenter(width / 2, height / 2))
        .force("charge", d3.forceManyBody().strength(-500))
        .force("link", d3.forceLink().id(function (d) {
            return d.id;
        }).distance(200))

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

    var outerNodes = outerNodeContainer.selectAll("g.outer")
        .data(outerGraph.nodes, function (d) { return d.id; })
        .join("circle")
        .attr("class", d => `outer outer_${d.id}`)
        .style("fill", "pink")
        .style("stroke", "blue")
        .attr("r", 40)
        .call(d3.drag()
            .on("start", dragStarted)
            .on("drag", dragged)
            .on("end", dragEnded)
        )

    outerLayout
        .nodes(outerGraph.nodes)
        .on("tick", ticked)

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

    //###############################################
    //############## inner force Layout #############
    //###############################################

    var innerGraph = {
        "nodes": [
            { "id": "A1", "parent": "A" },
            { "id": "A2", "parent": "A" },
            { "id": "B1", "parent": "B" },
            { "id": "B2", "parent": "B" },
            { "id": "C1", "parent": "C" },
            { "id": "C2", "parent": "C" }
        ],
        "links": [
            {
                "source": "A1",
                "target": "A2"
            },
            {
                "source": "A2",
                "target": "B2"
            },
            {
                "source": "A1",
                "target": "C2"
            },
            {
                "source": "B1",
                "target": "B2"
            },
            {
                "source": "B1",
                "target": "C1"
            },
            {
                "source": "C2",
                "target": "C1"
            }
        ]
    }

    var innerlayout = d3.forceSimulation()
        .force("center", d3.forceCenter(width / 2, height / 2))
        .force("charge", d3.forceManyBody().strength(-500))
        .force("link", d3.forceLink().id(function (d) {
            return d.id;
        }).distance(200))

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

    var innerNodes = innerNodeContainer.selectAll("g.inner")
        .data(innerGraph.nodes, function (d) { return d.id; })
        .join("circle")
        .style("fill", "orange")
        .style("stroke", "blue")
        .attr("r", 6)
        .attr("class", d => `inner child_${d.id}`)
        .attr("id", function (d) { return d.id; })
        .call(d3.drag()
            .on("start", dragStarted)
            .on("drag", dragged)
            .on("end", dragEnded)
        )

    innerlayout
        .nodes(innerGraph.nodes)
        .on("tick", ticked)

    innerlayout
        .force("link")
        .links(innerGraph.links)

    //###############################################
    //################## functons ###################
    //###############################################

    function ticked() {

        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 + ")";
        });

        innerNodes.attr("transform", function (d) {
            var parent = d3.select(`.outer_${d.parent}`);
            var pr = parent.node().getBoundingClientRect();
            var prx = pr.left + (pr.width / 2);
            var pry = pr.top + (pr.height / 2);
            var distance = Math.sqrt( ((d.x - prx) ** 2) + ((d.y - pry) ** 2 ));
            var ratio = 20 / distance;
            var childX = ((1 - ratio) * prx) + (ratio * d.x);
            var childY = ((1 - ratio) * pry) + (ratio * d.y);
            return "translate(" + (childX) + "," + (childY) + ")";
        });

        innerLinks.attr("x1", d => {
            var m1 = d3.select(`.child_${d.source.id}`).node().transform.baseVal[0].matrix;
            return m1.e;
        }).attr("y1", d => {
            var m1 = d3.select(`.child_${d.source.id}`).node().transform.baseVal[0].matrix;
            return m1.f;
        }).attr("x2", d => {
            var m2 = d3.select(`.child_${d.target.id}`).node().transform.baseVal[0].matrix;
            return m2.e;
        }).attr("y2", d => {
            var m2 = d3.select(`.child_${d.target.id}`).node().transform.baseVal[0].matrix;
            return m2.f;
        });

    }


    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 src="https://cdnjs.cloudflare.com/ajax/libs/d3/6.5.0/d3.min.js"></script>