Search code examples
javascriptd3.jsd3-force-directed

D3 force graph - zoom to node


I am trying to establish a 'clickToZoom' function. On node click the view should focus the clicked node. The event.transform object returns {k, x, y}. So far I thought I can receive those values from the clicked node and set the svg.attr("transform", "newValues"), which I do. Obvously it does not work like expected.

The view does change but seems to reset.

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

        var svg = d3.select("body").append("svg")
            .attr("width", width)
            .attr("height", height)
            .call(d3.zoom().on("zoom", function (event) {
                svg.attr("transform", event.transform)
                console.log(event.transform)
            }))
            .append("g")

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

        var data = {
            "nodes": [{
                "id": "A",
            },
            {
                "id": "B",
            },
            {
                "id": "C",
            },
            {
                "id": "D",
            },
            {
                "id": "E",
            },
            {
                "id": "F",
            },
            {
                "id": "G",
            },],
            "links": [{
                "source": "A",
                "target": "B"
            },
            {
                "source": "B",
                "target": "C"
            },
            {
                "source": "C",
                "target": "D"
            },
            {
                "source": "D",
                "target": "E"
            },
            {
                "source": "E",
                "target": "F"
            },
            {
                "source": "F",
                "target": "G"
            },]
        }

        var simulation = d3.forceSimulation()
            .force("size", d3.forceCenter(width / 2, height / 2))
            .force("charge", d3.forceManyBody().strength(-5000))
            .force("link", d3.forceLink().id(function (d) {
                return d.id
            }).distance(250))

        linksContainer = svg.append("g").attr("class", "linkscontainer")
        nodesContainer = svg.append("g").attr("class", "nodesContainer")


        links = linksContainer.selectAll(".linkPath")
            .data(data.links)
            .enter()
            .append("path")
            .attr("class", "linkPath")
            .attr("stroke", "red")
            .attr("fill", "transparent")
            .attr("stroke-width", 3)


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

                //d3.zoomTransform(d3.select(this))
                
                let nodeX = d.srcElement.__data__.x
                let nodeY = d.srcElement.__data__.y

                zoomToNode(nodeX, nodeY)                
            })

        nodes.selectAll("circle")
            .data(d => [d])
            .enter()
            .append("circle")
            .attr("class", "circle")
            .style("stroke", "blue")
            .attr("r", 40)

        simulation
            .nodes(data.nodes)
            .on("tick", tick)

        simulation
            .force("link")
            .links(data.links)

        function tick() {
            links.attr("d", function (d) {
                var dx = (d.target.x - d.source.x),
                    dy = (d.target.y - d.source.y),
                    dr = Math.sqrt(dx * dx + dy * dy)

                return "M" + d.source.x + "," + d.source.y + "A" + dr + "," + dr + " 0 0,1 " + d.target.x + "," + d.target.y;
            })

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

        function dragStarted(event, d) {
            if (!event.active) simulation.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) simulation.alphaTarget(0);
            d.fx = null;
            d.fy = null;
        }

        function zoomToNode(thisX, thisY) {
            let transformValue = {"k": 2, "x": thisX, "y": thisY}

            console.log(transformValue)

            svg.attr("transform", transformValue)

            svg.attr("transform", event.transform)
        }
    .link {
        stroke: #000;
        stroke-width: 1.5px;
    }

    .nodes {
        fill: whitesmoke;
        cursor: pointer;
    }

    .buttons {
        margin: 0 1em 0 0;
    }
<!DOCTYPE html>
<html lang="de">

<head>
    <meta name="viewport" content="width=device-width, user-scalable=no, initial-scale=1.0">
    <meta charset="utf-8">

    <!-- jQuery -->
    <script src="https://code.jquery.com/jquery-3.6.3.js"></script>
    <!-- D3 -->
    <script src="https://d3js.org/d3.v7.min.js"></script>
    <!-- fontawesome stylesheet https://fontawesome.com/ -->
    <script src="https://kit.fontawesome.com/98a5e27706.js" crossorigin="anonymous"></script>
</head>


<body>

</body>

</html>


Solution

  • This example has reasonable utility for your desired outcome. The changes to your original based on this are:

    • Assign d3.zoom to a variable rather than call it when creating the svg
    • Assign the first g attached to the svg to a separate variable and then attach linksContainer and nodesContainer to that g
    • The independent zoom reference acts on this g and not svg.

    You can then utilise the code from the Observable in the click handler (which has a signature of function(event, d)):

    svg.transition().duration(700).call(
        zoom.transform,
        d3.zoomIdentity.translate((width / 2), (height / 2)).scale(1.2).translate(-d.x, -d.y),
        d3.pointer(event)
    );
    

    The 'reset' is caused because a the use of svg.attr("transform", ..some translation..) does not update the zoom so the moment you pan/ zoom again after setting this transform it resets to the last point that you had with the normal zoom.

    The click handler logic invokes zoom.transform and sets the zoom at 1.2 (which you can adjust) and translates relative to the x and y of the clicked node.

    If you use the signature of function(event, d) in the click handler you can reference the coordinates of d more easily than getting to them through event.

    var width = 500,
        height = 200;
    
    var zoom = d3.zoom()
        .on("zoom", function(event) {
            g.attr("transform", event.transform)
        })
        
    var svg = d3.select("body").append("svg")
        .attr("width", width)
        .attr("height", height)
    
    var g = svg.append("g")
    svg.call(zoom)
    
    ////////////////////////
    // outer force layout
    var simulation = d3.forceSimulation()
        .force("size", d3.forceCenter(width / 2, height / 2))
        .force("charge", d3.forceManyBody().strength(-5000))
        .force("link", d3.forceLink().id(function (d) {
            return d.id
        }).distance(250))
    
    linksContainer = g.append("g").attr("class", "linkscontainer")
    nodesContainer = g.append("g").attr("class", "nodesContainer")
    
    links = linksContainer.selectAll(".linkPath")
        .data(data.links)
        .enter()
        .append("path")
        .attr("class", "linkPath")
        .attr("stroke", "red")
        .attr("fill", "transparent")
        .attr("stroke-width", 3)
    
    
    nodes = nodesContainer.selectAll(".nodes")
        .data(data.nodes, function (d) {
            return d.id;
        })
        .enter()
        .append("g")
        .attr("class", "nodes")
        .call(d3.drag()
            .on("start", dragStarted)
            .on("drag", dragged)
            .on("end", dragEnded)
        )
        .on("click", function(event, d) {
            //event.stopPropagation();
            svg.transition().duration(700).call(
                zoom.transform,
                d3.zoomIdentity.translate((width / 2), (height / 2)).scale(1.2).translate(-d.x, -d.y),
                d3.pointer(event)
            );
        })
    
    nodes.selectAll("circle")
        .data(d => [d])
        .enter()
        .append("circle")
        .attr("class", "circle")
        .style("stroke", "blue")
        .attr("r", 40)
    
    simulation
        .nodes(data.nodes)
        .on("tick", tick)
    
    simulation
        .force("link")
        .links(data.links)
    
    function tick() {
        links.attr("d", function (d) {
            var dx = (d.target.x - d.source.x),
                dy = (d.target.y - d.source.y),
                dr = Math.sqrt(dx * dx + dy * dy)
    
            return "M" + d.source.x + "," + d.source.y + "A" + dr + "," + dr + " 0 0,1 " + d.target.x + "," + d.target.y;
        })
    
        nodes
            .attr("transform", d => `translate(${d.x}, ${d.y})`);
    }
    
    function dragStarted(event, d) {
        if (!event.active) simulation.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) simulation.alphaTarget(0);
        d.fx = null;
        d.fy = null;
    }
    .link {
            stroke: #000;
            stroke-width: 1.5px;
        }
    
        .nodes {
            fill: whitesmoke;
            cursor: pointer;
        }
    
        .buttons {
            margin: 0 1em 0 0;
        }
    <!DOCTYPE html>
    <html lang="de">
    
    <head>
        <meta name="viewport" content="width=device-width, user-scalable=no, initial-scale=1.0">
        <meta charset="utf-8">
    
        <!-- jQuery -->
        <script src="https://code.jquery.com/jquery-3.6.3.js"></script>
        <!-- D3 -->
        <script src="https://d3js.org/d3.v7.min.js"></script>
        <!-- fontawesome stylesheet https://fontawesome.com/ -->
        <script src="https://kit.fontawesome.com/98a5e27706.js" crossorigin="anonymous"></script>
    </head>
    
    
    <body>
    <script>
    var data = {
        "nodes": [{
            "id": "A",
        },
        {
            "id": "B",
        },
        {
            "id": "C",
        },
        {
            "id": "D",
        },
        {
            "id": "E",
        },
        {
            "id": "F",
        },
        {
            "id": "G",
        },],
        "links": [{
            "source": "A",
            "target": "B"
        },
        {
            "source": "B",
            "target": "D"
        },
        {
            "source": "C",
            "target": "F"
        },
        {
            "source": "D",
            "target": "A"
        },
        {
            "source": "E",
            "target": "B"
        },
        {
            "source": "F",
            "target": "A"
        },
        {
            "source": "F",
            "target": "G"
        },]
    }
    </script>
    
    </body>
    
    </html>