Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

D3v6 zoom causing wrong pointer position

I got a D3v6 graph, which contains a context menu for the nodes. If clicked, its possible to add a link to another node. My problem is the zoom function, which I declared on the root SVG:

    var svg = d3.select("svg")
        .attr("class", "canvas")
        .attr("width", width)
        .attr("height", height)
        .call(d3.zoom().on("zoom", function (event) {
            svg.attr("transform", event.transform)
        }))
        //.on("mousedown", mousedownSVG)
        .on("mousemove", mousemoveSVG)
        .on("mouseup", mouseupSVG)
        .append("g")

Case 1: do not zoom, use the Add Link function to connect node 2 with node 0. No issue at all

Case 2: Reload snippet, zoom in and afterwards use the Add Link function. The dragLine is not at the mouse position. Somehow the zoom function destroys the scale of my graph, which cause to return wrong positions.

<!DOCTYPE html>
<html>

<head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">

    <title>D3v6 Refactor</title>
    <!-- call external d3.js framework -->
    <script src="https://d3js.org/d3.v6.js"></script>
</head>

<style>
    body {
        overflow: hidden;
        background-color: rgb(220, 220, 220);
        margin: 0px;
    }

    .node {
        stroke: white;
        stroke-width: 2px;
        cursor: pointer;
    }

    .node:hover {
        stroke: red
    }

    .link {
        fill: none;
        cursor: default;
        stroke: rgb(0, 0, 0);
        stroke-width: 3px;
    }

    .dragline {
        stroke-width: 2;
        pointer-events: none;
    }

    .dragline.hidden {
        stroke-width: 0;
    }

    #context-menu-node {
        font-family: "Open Sans", sans-serif;
        position: fixed;
        z-index: 10000;
        width: 190px;
        background: whitesmoke;
        border: 2px;
        border-radius: 6px;
        border-color: white;
        border-style: solid;
        transform: scale(0);
        transform-origin: top left;
    }

    #context-menu-node.active {
        transform: scale(1);
        transition: transform 200ms ease-in-out;
    }

    #context-menu-node .item {
        padding: 8px 10px;
        font-size: 15px;
        color: black;
    }

    #context-menu-node .item i {
        display: inline-block;
        margin-right: 5px;
    }

    #context-menu-node hr {
        margin: 5px 0px;
        border-color: whitesmoke;
    }

    #context-menu-node .item:hover {
        background: lightblue;
    }
</style>

<body>
    <!-- right click context menu node -->
    <div id="context-menu-node">
        <div id="addLink" class="item">
            <i class="fas fa-link"></i></i> Add Link
        </div>
        <hr>
        <div id="addNode" class="item">
            <i class="fas fa-link"></i></i> Add Node
        </div>
    </div>

    <svg id="svg"> </svg>

    <script>
        var graph = {
            "nodes": [
                {
                    "id": 0,
                },
                {
                    "id": 1,
                },
                {
                    "id": 2,
                }
            ],
            "links": [
                {
                    "source": 0,
                    "target": 1,
                },
                {
                    "source": 1,
                    "target": 2,
                }
            ]
        }

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

        var svg = d3.select("svg")
            .attr("class", "canvas")
            .attr("width", width)
            .attr("height", height)
            .call(d3.zoom().on("zoom", function (event) {
                svg.attr("transform", event.transform)
            }))
            //.on("mousedown", mousedownSVG)
            .on("mousemove", mousemoveSVG)
            .on("mouseup", mouseupSVG)
            .append("g")

        // remove zoom on dblclick listener
        d3.select("svg").on("dblclick.zoom", null)

        var linkContainer = svg.append("g").attr("class", "linkContainer")
        var nodeContainer = svg.append("g").attr("class", "nodeContainer")

        var mousedownNode = "";
        var targetLink = "";
        var addLinkClicked = false;

        var simulation = d3.forceSimulation()
            .force("link", d3.forceLink().id(function (d) {
                return d.id;
            }).distance(100))
            .force("charge", d3.forceManyBody().strength(-500))
            .force("center", d3.forceCenter(width / 2, height / 2))
            .force("collision", d3.forceCollide().radius(50))

        var dragLine = svg.append("path")
            .attr("class", "link dragline")
            .attr("d", "M0,0 L0,0");

        initialize()

        function initialize() {

            link = linkContainer.selectAll(".link")
                .data(graph.links)
                .join("line")
                .attr("class", "link")

            node = nodeContainer.selectAll(".node")
                .data(graph.nodes, d => d.id)
                .join("g")
                .attr("class", "node")

                .call(d3.drag()
                    .on("start", dragStarted)
                    .on("drag", dragged)
                    .on("end", dragEnded)
                )

            node
                .data(graph.nodes, d => d.id)
                .append("circle")
                .attr("r", 30)
                .style("fill", "whitesmoke")
                .on("contextmenu", contextMenuNode)
                .on("mouseenter", (event, d) => {
                    targetLink = d
                })
                .on("mousedown", (event, d) => {
                    mousedownNode = d;
                })
                .on("click", function (event, d) {
                    if (!addLinkClicked) return

                    // needed by FF
                    dragLine
                        .classed('hidden', true)

                    console.log(targetLink.id)

                    graph.links.push({ source: thisElement.id, target: targetLink.id });

                    addLinkClicked = false;

                    initialize();

                    simulation.alpha(0.3).restart()
                });

            node
                .append("text")
                .style("class", "icon")
                .attr("font-family", "FontAwesome")
                .attr("dominant-baseline", "central")
                .attr("text-anchor", "middle")
                .attr("font-size", 30)
                .attr("fill", "black")
                .attr("stroke-width", "0px")
                .attr("pointer-events", "none")
                .text((d) => {
                    return d.id
                })

            simulation
                .nodes(graph.nodes)
                .on("tick", ticked);

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

        }


        function contextMenuNode(event, d) {
            thisElement = d

            event.preventDefault()

            document.getElementById("context-menu-node").classList.remove("active")

            var contextMenu = document.getElementById("context-menu-node")
            contextMenu.style.top = event.clientY + "px"
            contextMenu.style.left = event.clientX + "px"
            contextMenu.classList.add("active")

            window.addEventListener("click", function () {
                contextMenu.classList.remove("active")
            })

            document.getElementById("addNode").addEventListener("click", addNode)
            document.getElementById("addLink").addEventListener("click", addLink)
        }

        function addLink() {
            addLinkClicked = true

            const point = d3.pointer(event)

            dragLine
                .classed("hidden", false)
                .attr('d', `M${thisElement.x},${thisElement.y}L${thisElement.x},${thisElement.y}`);
        }

        function addNode() {
            var newID = Math.floor(Math.random() * 1000)
            
            graph.nodes.push({
                "id": newID
            })

            graph.links.push({ source: newID, target: thisElement.id })

            initialize()

            simulation.alpha(0.3).restart()
        }


        /*
        function mousedownSVG(event) {
            var point = d3.pointer(event)
            
            var newNode = { id: graph.nodes.length, x: point[0], y: point[1] }
            
            graph.nodes.push(newNode)
            
            initialize()
            
            simulation.alpha(0.3).restart()
        }
        */

        function mousemoveSVG(event) {
            if (!addLinkClicked) return

            const point = d3.pointer(event)

            // update drag line
            dragLine.attr('d', `M${mousedownNode.x},${mousedownNode.y}L${point[0]},${point[1]}`);
        }

        function mouseupSVG(event) {
            if (mousedownNode) {
                //hide drag line
                dragLine
                    .classed("hidden", true)
            }
        }

        function ticked() {
            // update link positions
            link
                .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;
                });


            // update node positions
            node
                .attr("transform", function (d) {
                    return "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 = undefined;
            d.fy = undefined;
        }
    </script>
</body>

</html>

Any idea how to solve this?

like image 365
ICoded Avatar asked Apr 01 '26 05:04

ICoded


1 Answers

The general issue is dealt with in this question with accepted answer by @Andrew Reid

For your specific issue, try the following to illustrate what is happening:

  • include console.log(point) in your mousemoveSVG function
  • run the snippet
  • right click node 2 and add a link
  • move the pointer to the centre of the 'zero' node and observe [x, y] in the console
  • do some pan and zoom
  • move the dragline about - you will see the [x, y] has changed and the dragline is off
  • stop the snippet and in mousemoveSVG update this line to const point = d3.pointer(event, svg.node());
  • now repeat the test above - the pointer in the centre of the 'zero' node should have the same [x,y] for any different pan/ zoom actions

This 2nd argument to d3.pointer called target:

If the target is an SVG element, the event’s coordinates are transformed using the inverse of the screen coordinate transformation matrix.

Transforming the [x,y] of the pointer with this screen coordinate transformation matrix is basically saying keep my mouse in step with the zoom.

Here is your full code with the update - I commented out the log as it stuffs up the snippet, but you can uncomment to try the steps above.

<!DOCTYPE html>
<html>

<head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">

    <title>D3v6 Refactor</title>
    <!-- call external d3.js framework -->
    <script src="https://d3js.org/d3.v6.js"></script>
</head>

<style>
    body {
        overflow: hidden;
        background-color: rgb(220, 220, 220);
        margin: 0px;
    }

    .node {
        stroke: white;
        stroke-width: 2px;
        cursor: pointer;
    }

    .node:hover {
        stroke: red
    }

    .link {
        fill: none;
        cursor: default;
        stroke: rgb(0, 0, 0);
        stroke-width: 3px;
    }

    .dragline {
        stroke-width: 2;
        pointer-events: none;
    }

    .dragline.hidden {
        stroke-width: 0;
    }

    #context-menu-node {
        font-family: "Open Sans", sans-serif;
        position: fixed;
        z-index: 10000;
        width: 190px;
        background: whitesmoke;
        border: 2px;
        border-radius: 6px;
        border-color: white;
        border-style: solid;
        transform: scale(0);
        transform-origin: top left;
    }

    #context-menu-node.active {
        transform: scale(1);
        transition: transform 200ms ease-in-out;
    }

    #context-menu-node .item {
        padding: 8px 10px;
        font-size: 15px;
        color: black;
    }

    #context-menu-node .item i {
        display: inline-block;
        margin-right: 5px;
    }

    #context-menu-node hr {
        margin: 5px 0px;
        border-color: whitesmoke;
    }

    #context-menu-node .item:hover {
        background: lightblue;
    }
</style>

<body>
    <!-- right click context menu node -->
    <div id="context-menu-node">
        <div id="addLink" class="item">
            <i class="fas fa-link"></i></i> Add Link
        </div>
        <hr>
        <div id="addNode" class="item">
            <i class="fas fa-link"></i></i> Add Node
        </div>
    </div>

    <svg id="svg"> </svg>

    <script>
        var graph = {
            "nodes": [
                {
                    "id": 0,
                },
                {
                    "id": 1,
                },
                {
                    "id": 2,
                }
            ],
            "links": [
                {
                    "source": 0,
                    "target": 1,
                },
                {
                    "source": 1,
                    "target": 2,
                }
            ]
        }

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

        var svg = d3.select("svg")
            .attr("class", "canvas")
            .attr("width", width)
            .attr("height", height)
            .call(d3.zoom().on("zoom", function (event) {
                svg.attr("transform", event.transform)
            }))
            //.on("mousedown", mousedownSVG)
            .on("mousemove", mousemoveSVG)
            .on("mouseup", mouseupSVG)
            .append("g")

        // remove zoom on dblclick listener
        d3.select("svg").on("dblclick.zoom", null)

        var linkContainer = svg.append("g").attr("class", "linkContainer")
        var nodeContainer = svg.append("g").attr("class", "nodeContainer")

        var mousedownNode = "";
        var targetLink = "";
        var addLinkClicked = false;

        var simulation = d3.forceSimulation()
            .force("link", d3.forceLink().id(function (d) {
                return d.id;
            }).distance(100))
            .force("charge", d3.forceManyBody().strength(-500))
            .force("center", d3.forceCenter(width / 2, height / 2))
            .force("collision", d3.forceCollide().radius(50))

        var dragLine = svg.append("path")
            .attr("class", "link dragline")
            .attr("d", "M0,0 L0,0");

        initialize()

        function initialize() {

            link = linkContainer.selectAll(".link")
                .data(graph.links)
                .join("line")
                .attr("class", "link")

            node = nodeContainer.selectAll(".node")
                .data(graph.nodes, d => d.id)
                .join("g")
                .attr("class", "node")

                .call(d3.drag()
                    .on("start", dragStarted)
                    .on("drag", dragged)
                    .on("end", dragEnded)
                )

            node
                .data(graph.nodes, d => d.id)
                .append("circle")
                .attr("r", 30)
                .style("fill", "whitesmoke")
                .on("contextmenu", contextMenuNode)
                .on("mouseenter", (event, d) => {
                    targetLink = d
                })
                .on("mousedown", (event, d) => {
                    mousedownNode = d;
                })
                .on("click", function (event, d) {
                    if (!addLinkClicked) return

                    // needed by FF
                    dragLine
                        .classed('hidden', true)

                    console.log(targetLink.id)

                    graph.links.push({ source: thisElement.id, target: targetLink.id });

                    addLinkClicked = false;

                    initialize();

                    simulation.alpha(0.3).restart()
                });

            node
                .append("text")
                .style("class", "icon")
                .attr("font-family", "FontAwesome")
                .attr("dominant-baseline", "central")
                .attr("text-anchor", "middle")
                .attr("font-size", 30)
                .attr("fill", "black")
                .attr("stroke-width", "0px")
                .attr("pointer-events", "none")
                .text((d) => {
                    return d.id
                })

            simulation
                .nodes(graph.nodes)
                .on("tick", ticked);

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

        }


        function contextMenuNode(event, d) {
            thisElement = d

            event.preventDefault()

            document.getElementById("context-menu-node").classList.remove("active")

            var contextMenu = document.getElementById("context-menu-node")
            contextMenu.style.top = event.clientY + "px"
            contextMenu.style.left = event.clientX + "px"
            contextMenu.classList.add("active")

            window.addEventListener("click", function () {
                contextMenu.classList.remove("active")
            })

            document.getElementById("addNode").addEventListener("click", addNode)
            document.getElementById("addLink").addEventListener("click", addLink)
        }

        function addLink() {
            addLinkClicked = true

            const point = d3.pointer(event)

            dragLine
                .classed("hidden", false)
                .attr('d', `M${thisElement.x},${thisElement.y}L${thisElement.x},${thisElement.y}`);
        }

        function addNode() {
            var newID = Math.floor(Math.random() * 1000)
            
            graph.nodes.push({
                "id": newID
            })

            graph.links.push({ source: newID, target: thisElement.id })

            initialize()

            simulation.alpha(0.3).restart()
        }


        /*
        function mousedownSVG(event) {
            var point = d3.pointer(event)
            
            var newNode = { id: graph.nodes.length, x: point[0], y: point[1] }
            
            graph.nodes.push(newNode)
            
            initialize()
            
            simulation.alpha(0.3).restart()
        }
        */

        function mousemoveSVG(event) {
            if (!addLinkClicked) return

            const point = d3.pointer(event, svg.node()); // <--- add svg.node() as 'target'
            //console.log(point);

            // update drag line
            dragLine.attr('d', `M${mousedownNode.x},${mousedownNode.y}L${point[0]},${point[1]}`);
        }

        function mouseupSVG(event) {
            if (mousedownNode) {
                //hide drag line
                dragLine
                    .classed("hidden", true)
            }
        }

        function ticked() {
            // update link positions
            link
                .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;
                });


            // update node positions
            node
                .attr("transform", function (d) {
                    return "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 = undefined;
            d.fy = undefined;
        }
    </script>
</body>

</html>
like image 126
Robin Mackenzie Avatar answered Apr 03 '26 17:04

Robin Mackenzie



Donate For Us

If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!