D3 link breaks during special removal case

Viewed 159

I added a context menu in my graph, where I can use my add and removal function. A node which receives connections can´t be removed, a alert informs the user. Further I can "endless" add nodes. Now comes the interesting "problem" part.

Case 1: If nodes are removed ordered from highest to smallest and a new node is added afterwards, its fine. For example, remove node 8 and add a new node anywhere. Or remove node 8, 7, 6 and add a node afterwards will be also fine.

Case 2: Remove a node, which is not on the last array position and add a node anywhere afterwards. The connection will break. For example, remove node 5 and add a new node on 3. The connection of node 8 will break.

I am getting mental with this issue.

<!DOCTYPE html>
<html>

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

    <title>Playground D3v6</title>
    <!-- favcon -->
    <link rel="icon" href="https://networkrepository.com/favicon.png">
    <!-- call external d3.js framework -->
    <script src="https://d3js.org/d3.v6.js"></script>
    <!-- import multiselection framework -->
    <script src="https://d3js.org/d3-selection-multi.v1.js"></script>
    <!-- import "font awesome" stylesheet https://fontawesome.com/ -->
    <script src="https://kit.fontawesome.com/39094309d6.js" crossorigin="anonymous"></script>
</head>

<style>
    body {
        overflow: hidden;
        margin: 0px;
    }

    .canvas {
        background-color: rgb(220, 220, 220);
    }

    .link {
        stroke: rgb(0, 0, 0);
        stroke-width: 1px;
    }

    circle {
        background-color: whitesmoke;
    }

    .tooltip {
        font-family: "Open Sans", sans-serif;
        position: absolute;
        text-align: left;
        background: rgb(245, 245, 245);
        border: 2px;
        border-radius: 6px;
        border-color: rgb(255, 255, 255);
        border-style: solid;
        pointer-events: none;
        line-height: 150%;
        padding: 8px 10px;
    }

    #context-menu {
        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.active {
        transform: scale(1);
        transition: transform 200ms ease-in-out;
    }

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

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

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

    #context-menu .item:hover {
        background: lightblue;
    }

</style>

<body>
    <!-- right click context menu -->
    <div id="context-menu">
        <div id="addObject" class="item">
            <i class="fa fa-plus-circle"></i> Add Node
        </div>
        <div id="removeObject" class="item">
            <i class="fa fa-minus-circle"></i> Remove Node
        </div>
    </div>

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


    <!-- call script where the main application is written -->
    <script>
        var graph = {
            "nodes": [{
                "id": 0,
                "type": "Company",
                "name": "Company",
                "icon": "\uf1ad",
                "parent": 0
            },
            {
                "id": 1,
                "type": "Software",
                "name": "Software_1",
                "icon": "\uf7b1",
                "parent": 0
            },
            {
                "id": 2,
                "type": "Software",
                "name": "Software_2",
                "icon": "\uf78d",
                "parent": 0
            },
            {
                "id": 3,
                "type": "Software",
                "name": "Software_3",
                "icon": "\ue084",
                "parent": 0
            },
            {
                "id": 4,
                "type": "Software",
                "name": "Software_4",
                "icon": "\ue084",
                "parent": 0
            },
            {
                "id": 5,
                "type": "Software",
                "name": "Software_5",
                "icon": "\ue084",
                "parent": 3
            },
            {
                "id": 6,
                "type": "Software",
                "name": "Software_6",
                "icon": "\ue084",
                "parent": 3
            },
            {
                "id": 7,
                "type": "Software",
                "name": "Software_7",
                "icon": "\ue084",
                "parent": 4
            },
            {
                "id": 8,
                "type": "Software",
                "name": "Software_8",
                "icon": "\ue084",
                "parent": 4
            }
            ],
            "links": [{
                "source": 1,
                "target": 0,
                "type": "uses"
            },
            {
                "source": 2,
                "target": 0,
                "type": "uses"
            },
            {
                "source": 3,
                "target": 0,
                "type": "uses"
            },
            {
                "source": 4,
                "target": 0,
                "type": "uses"
            },
            {
                "source": 5,
                "target": 3,
                "type": "uses"
            },
            {
                "source": 6,
                "target": 3,
                "type": "uses"
            },
            {
                "source": 7,
                "target": 4,
                "type": "uses"
            },
            {
                "source": 8,
                "target": 4,
                "type": "uses"
            }
            ]
        }

        // declare initial variables
        var svg = d3.select("svg")
        width = window.innerWidth
        height = window.innerHeight
        node = null
        link = null
        thisNode = null;
        d = null;
        isParent = false;

        // define cavnas area to draw everything
        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)
            }))
            .append("g")

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

        // append markers to svg
        svg.append('defs').append('marker')
            .attrs({
                'id': 'arrowhead',
                'viewBox': '-0 -5 10 10',
                'refX': 14,
                'refY': 0,
                'orient': 'auto',
                'markerWidth': 30,
                'markerHeight': 30,
                'xoverflow': 'visible'
            })
            .append('svg:path')
            .attr('d', 'M 0,-2 L 4 ,0 L 0,2')
            .attr('fill', 'black')
            .style('stroke', 'none');

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

        // iniital force simulation
        var simulation = d3.forceSimulation()
            .force("link", d3.forceLink().id(function (d) {
                return d.id;
            }).distance(100))
            .force("charge", d3.forceManyBody().strength(-400))
            .force("center", d3.forceCenter(width / 2, height / 2))
            .force("attraceForce", d3.forceManyBody().strength(70));

        var tooltip = d3.select("body").append("div")
            .attr("class", "tooltip")
            .style("opacity", 0);

        //create links
        link = linksContainer.selectAll(".link")
            .data(graph.links, d => d.id)
            .enter()
            .append("line")
            .attr("class", "link")
            .style("pointer-events", "none")
            .attr('marker-end', 'url(#arrowhead)')

        node = nodesContainer.selectAll(".node")
            .data(graph.nodes, d => d.id)
            .enter()
            .append("g")
            .attr("class", "node")
            .attr("stroke", "white")
            .attr("stroke-width", "2px")
            .call(d3.drag()
            .on("start", dragStarted)
            .on("drag", dragged)
            .on("end", dragEnded)
            )
            

        node.append("circle")
            .attr("r", 30)
            .style("fill", "whitesmoke")
            .on("mouseenter", mouseEnter)
            .on("mouseleave", mouseLeave)
            .on("contextmenu", contextMenu)

        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(function (d) {
                return d.id
            })

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

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

            function mouseEnter(event, d) {
            thisNode = d

            d3.select(this)
                .style("fill", "lightblue")
            tooltip.transition()
                .style("opacity", 1)
            tooltip.html(
                "ID: " + d.id + "<br/>" +
                "Name: " + d.name + "<br/>" +
                "Typ: " + d.type + "<br/>" +
                "Parent: " + d.parent)
                .style("left", (event.pageX) + 30 + "px")
                .style("top", (event.pageY - 80) + "px");
        }

        function mouseLeave(d) {
            switch (d.name) {
                case ("power-plug"):
                    tooltip.transition()
                        .style("opacity", 0);

                    return
                default:
                    d3.select(this).style("fill", "whitesmoke")

                    tooltip.transition()
                        .style("opacity", 0);

            }
        }

        function contextMenu(event, d) {
            thisNode = d

            tooltip.transition()
                .style("opacity", 0);

            event.preventDefault()

            var contextMenu = document.getElementById("context-menu")
            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("addObject").addEventListener("click", addNodeClicked)
            document.getElementById("removeObject").addEventListener("click", removeNodeClicked)
        }

        function addNodeClicked() {

            addNode(thisNode)
        }

        function addNode(d) {
            var newID = Math.floor(Math.random()*100000)

            graph.nodes.push({
                "id": newID,
                "type": "software",
                "name": "Software_" + newID,
                "icon": "\ue084",
                "parent": d.id,
            })

            graph.links.push({
                source: newID,
                target: d.id,
                type: "uses"
            })

            link = linksContainer.selectAll(".link")
                .data(graph.links)
                .enter()
                .append("line")
                .attr("class", "link")
                .style("pointer-events", "none")
                .attr('marker-end', 'url(#arrowhead)')
                .style("display", "block")
                .merge(link)

            node = nodesContainer.selectAll(".node")
                .data(graph.nodes)
                .enter()
                .append("g")
                .attr("class", "node")
                .attr("stroke", "white")
                .attr("stroke-width", "2px")
                .call(d3.drag()
                .on("start", dragStarted)
                .on("drag", dragged)
                .on("end", dragEnded)
                )
                
                .merge(node)

            node.append("circle")
                .attr("r", 30)
                .style("fill", "whitesmoke")
                .on("click", addNodeClicked)
                .on("mouseenter", mouseEnter)
                .on("mouseleave", mouseLeave)
                .on("contextmenu", contextMenu)
                .merge(node)

            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(function (d) {
                    return d.id
                })
                .merge(node)

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

            //reheat the simulation
            simulation.alpha(0.3).restart()
            
            /*
            
            console.log("addNode: ")
            console.log(graph.nodes)
            console.log("---------")
            
            console.log("addLink: ")
            console.log(graph.links)
            console.log("---------")
            */
        }

        function removeNodeClicked() {
            removeNode(thisNode)
        }

        function removeNode(d) {
            var hasNeighborNodes = false

            link.filter((l) => {
                if (d.id == l.target.id) {
                    hasNeighborNodes = true
                }

            })

            if (hasNeighborNodes) {
                alert("Object can´t be deleted, beause of incoming connections. Please re-arrange or delete incoming connections first.")
                hasNeighborNodes = false

            } else if (!hasNeighborNodes) {


                var indexOfNodes = graph.nodes.indexOf(d)

                var indexOfLinks = graph.links.findIndex(element => element.source.id == d.id)
                
                graph.links.splice(indexOfLinks, 1)
                
                linksContainer.selectAll(".link")
                .data(graph.links)
                .exit()
                .remove()
                

                graph.nodes.splice(indexOfNodes, 1)

                node
                    .data(graph.nodes, d => d.id)
                    .exit()
                    .remove()

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

                //reheat the simulation
                simulation.alpha(0.3).restart()

            }


        }



        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>

1 Answers

When you remove a node, the node selection object isn't being updated.

To update it, you should change:

node
    .data(graph.nodes, d => d.id)
    .exit()
    .remove()

to something like:

// no need to merge with enter, since you're only removing data
node = node
    .data(graph.nodes, d => d.id);
node.exit()
    .remove();

Or alternatively, using the recommended practice of selection.join:

node = node
    .data(graph.nodes, d => d.id)
    .join();

What's Happening

When you're removing an element, if you don't update node to the new selection, your selection has an extra element (pointing to a detached DOM node). Then when you add a new node, this bit of code

node = nodesContainer.selectAll(".node")
    .data(graph.nodes)
    .enter().append("g")
    //
    .merge(node);

is trying to merge two selections of the same size. The new selection, from .enter(), has empty slots for all nodes except for the very last element (for example, if you're adding another node to 9 already existing nodes, .enter() will return a selection of size 10, but the first 9 are empty). The old selection, node, has all the slots filled, though one of them is pointing to a detached DOM node. Because they're the same size, and elements are only merged over to the new selection if the slot is empty, the last element of the old selection isn't merged. If it isn't the removed node, then it no longer is in the selection, and doesn't get updated.

Also update link

As pointed out in the comments, there's also a bug where if you remove consecutive nodes in the same chain, it shows an alert. This is similarly due to not updating the link selection object, where it's using outdated data to check for whether a node can be removed.

To update link you should change:

linksContainer.selectAll(".link")
    .data(graph.links)
    .exit()
    .remove()

to something like:

link = linksContainer.selectAll(".link")
    .data(graph.links)
    .join()

Furthermore:

You indicate you also want to better understand the update process. Your code implies that you might not be quite understanding the nuances, particularly when you're adding new nodes.

Using node.append, appends a node to every group in the selection, even the old groups that were already added. You can verify this by looking at the DOM inspector, and after adding a few nodes, there are multiple circle and text child elements on each node. Instead, you should append them only to the new nodes.

It'd look something like this:

var newNodes = nodesContainer.selectAll(".node")
    .data(graph.nodes)
    .enter().append("g")
    // ...

newNodes.append("circle")
    // ...

node = newNodes.merge(node);

Also, you end some of the chains with .merge(node), which creates a new selection, which tries to combine newly appended elements (circles or texts) with their parent elements, and doesn't do anything if you don't use it or assign it to anything. You may want to read up on what .merge does and what it's meant for, though you may just want to learn about the newer .join method instead.

All Together:

var graph = {
    "nodes": [{
        "id": 0,
        "type": "Company",
        "name": "Company",
        "icon": "\uf1ad",
        "parent": 0
    },
    {
        "id": 1,
        "type": "Software",
        "name": "Software_1",
        "icon": "\uf7b1",
        "parent": 0
    },
    {
        "id": 2,
        "type": "Software",
        "name": "Software_2",
        "icon": "\uf78d",
        "parent": 0
    },
    {
        "id": 3,
        "type": "Software",
        "name": "Software_3",
        "icon": "\ue084",
        "parent": 0
    },
    {
        "id": 4,
        "type": "Software",
        "name": "Software_4",
        "icon": "\ue084",
        "parent": 0
    },
    {
        "id": 5,
        "type": "Software",
        "name": "Software_5",
        "icon": "\ue084",
        "parent": 3
    },
    {
        "id": 6,
        "type": "Software",
        "name": "Software_6",
        "icon": "\ue084",
        "parent": 3
    },
    {
        "id": 7,
        "type": "Software",
        "name": "Software_7",
        "icon": "\ue084",
        "parent": 4
    },
    {
        "id": 8,
        "type": "Software",
        "name": "Software_8",
        "icon": "\ue084",
        "parent": 4
    }
    ],
    "links": [{
        "source": 1,
        "target": 0,
        "type": "uses"
    },
    {
        "source": 2,
        "target": 0,
        "type": "uses"
    },
    {
        "source": 3,
        "target": 0,
        "type": "uses"
    },
    {
        "source": 4,
        "target": 0,
        "type": "uses"
    },
    {
        "source": 5,
        "target": 3,
        "type": "uses"
    },
    {
        "source": 6,
        "target": 3,
        "type": "uses"
    },
    {
        "source": 7,
        "target": 4,
        "type": "uses"
    },
    {
        "source": 8,
        "target": 4,
        "type": "uses"
    }
    ]
}

// declare initial variables
var svg = d3.select("svg")
width = window.innerWidth
height = window.innerHeight
node = null
link = null
thisNode = null;
d = null;
isParent = false;

// define cavnas area to draw everything
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)
    }))
    .append("g")

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

// append markers to svg
svg.append('defs').append('marker')
    .attrs({
        'id': 'arrowhead',
        'viewBox': '-0 -5 10 10',
        'refX': 14,
        'refY': 0,
        'orient': 'auto',
        'markerWidth': 30,
        'markerHeight': 30,
        'xoverflow': 'visible'
    })
    .append('svg:path')
    .attr('d', 'M 0,-2 L 4 ,0 L 0,2')
    .attr('fill', 'black')
    .style('stroke', 'none');

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

// iniital force simulation
var simulation = d3.forceSimulation()
    .force("link", d3.forceLink().id(function (d) {
        return d.id;
    }).distance(100))
    .force("charge", d3.forceManyBody().strength(-400))
    .force("center", d3.forceCenter(width / 2, height / 2))
    .force("attraceForce", d3.forceManyBody().strength(70));

var tooltip = d3.select("body").append("div")
    .attr("class", "tooltip")
    .style("opacity", 0);

//create links
link = linksContainer.selectAll(".link")
    .data(graph.links, d => d.id)
    .enter()
    .append("line")
    .attr("class", "link")
    .style("pointer-events", "none")
    .attr('marker-end', 'url(#arrowhead)')

node = nodesContainer.selectAll(".node")
    .data(graph.nodes, d => d.id)
    .enter()
    .append("g")
    .attr("class", "node")
    .attr("stroke", "white")
    .attr("stroke-width", "2px")
    .call(d3.drag()
    .on("start", dragStarted)
    .on("drag", dragged)
    .on("end", dragEnded)
    )
    

node.append("circle")
    .attr("r", 30)
    .style("fill", "whitesmoke")
    .on("mouseenter", mouseEnter)
    .on("mouseleave", mouseLeave)
    .on("contextmenu", contextMenu)

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(function (d) {
        return d.id
    })

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

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

    function mouseEnter(event, d) {
    thisNode = d

    d3.select(this)
        .style("fill", "lightblue")
    tooltip.transition()
        .style("opacity", 1)
    tooltip.html(
        "ID: " + d.id + "<br/>" +
        "Name: " + d.name + "<br/>" +
        "Typ: " + d.type + "<br/>" +
        "Parent: " + d.parent)
        .style("left", (event.pageX) + 30 + "px")
        .style("top", (event.pageY - 80) + "px");
}

function mouseLeave(d) {
    switch (d.name) {
        case ("power-plug"):
            tooltip.transition()
                .style("opacity", 0);

            return
        default:
            d3.select(this).style("fill", "whitesmoke")

            tooltip.transition()
                .style("opacity", 0);

    }
}

function contextMenu(event, d) {
    thisNode = d

    tooltip.transition()
        .style("opacity", 0);

    event.preventDefault()

    var contextMenu = document.getElementById("context-menu")
    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("addObject").addEventListener("click", addNodeClicked)
    document.getElementById("removeObject").addEventListener("click", removeNodeClicked)
}

function addNodeClicked() {

    addNode(thisNode)
}

function addNode(d) {
    var newID = Math.floor(Math.random()*100000)

    graph.nodes.push({
        "id": newID,
        "type": "software",
        "name": "Software_" + newID,
        "icon": "\ue084",
        "parent": d.id,
    })

    graph.links.push({
        source: newID,
        target: d.id,
        type: "uses"
    })

    link = linksContainer.selectAll(".link")
        .data(graph.links)
        .enter()
        .append("line")
        .attr("class", "link")
        .style("pointer-events", "none")
        .attr('marker-end', 'url(#arrowhead)')
        .style("display", "block")
        .merge(link)

    var newNodes = nodesContainer.selectAll(".node")
        .data(graph.nodes)
        .enter()
        .append("g")
        .attr("class", "node")
        .attr("stroke", "white")
        .attr("stroke-width", "2px")
        .call(d3.drag()
            .on("start", dragStarted)
            .on("drag", dragged)
            .on("end", dragEnded)
        )
        
    newNodes.append("circle")
        .attr("r", 30)
        .style("fill", "whitesmoke")
        .on("click", addNodeClicked)
        .on("mouseenter", mouseEnter)
        .on("mouseleave", mouseLeave)
        .on("contextmenu", contextMenu);

    newNodes.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(function (d) {
            return d.id
        });

    node = newNodes.merge(node)


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

    //reheat the simulation
    simulation.alpha(0.3).restart()
    
    /*
    
    console.log("addNode: ")
    console.log(graph.nodes)
    console.log("---------")
    
    console.log("addLink: ")
    console.log(graph.links)
    console.log("---------")
    */
}

function removeNodeClicked() {
    removeNode(thisNode)
}

function removeNode(d) {
    var hasNeighborNodes = false

    link.filter((l) => {
        if (d.id == l.target.id) {
            hasNeighborNodes = true
        }

    })

    if (hasNeighborNodes) {
        alert("Object can´t be deleted, beause of incoming connections. Please re-arrange or delete incoming connections first.")
        hasNeighborNodes = false

    } else if (!hasNeighborNodes) {


        var indexOfNodes = graph.nodes.indexOf(d)

        var indexOfLinks = graph.links.findIndex(element => element.source.id == d.id)
        
        graph.links.splice(indexOfLinks, 1)
        
        link = linksContainer.selectAll(".link")
          .data(graph.links)
          .join();
        

        graph.nodes.splice(indexOfNodes, 1)

        node = node
            .data(graph.nodes, d => d.id)
        node.exit()
            .remove()

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

        //reheat the simulation
        simulation.alpha(0.3).restart()

    }


}



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;
}
<!DOCTYPE html>
<html>

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

    <title>Playground D3v6</title>
    <!-- favcon -->
    <link rel="icon" href="https://networkrepository.com/favicon.png">
    <!-- call external d3.js framework -->
    <script src="https://d3js.org/d3.v6.js"></script>
    <!-- import multiselection framework -->
    <script src="https://d3js.org/d3-selection-multi.v1.js"></script>
    <!-- import "font awesome" stylesheet https://fontawesome.com/ -->
    <script src="https://kit.fontawesome.com/39094309d6.js" crossorigin="anonymous"></script>
</head>

<style>
    body {
        overflow: hidden;
        margin: 0px;
    }

    .canvas {
        background-color: rgb(220, 220, 220);
    }

    .link {
        stroke: rgb(0, 0, 0);
        stroke-width: 1px;
    }

    circle {
        background-color: whitesmoke;
    }

    .tooltip {
        font-family: "Open Sans", sans-serif;
        position: absolute;
        text-align: left;
        background: rgb(245, 245, 245);
        border: 2px;
        border-radius: 6px;
        border-color: rgb(255, 255, 255);
        border-style: solid;
        pointer-events: none;
        line-height: 150%;
        padding: 8px 10px;
    }

    #context-menu {
        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.active {
        transform: scale(1);
        transition: transform 200ms ease-in-out;
    }

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

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

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

    #context-menu .item:hover {
        background: lightblue;
    }

</style>

<body>
    <!-- right click context menu -->
    <div id="context-menu">
        <div id="addObject" class="item">
            <i class="fa fa-plus-circle"></i> Add Node
        </div>
        <div id="removeObject" class="item">
            <i class="fa fa-minus-circle"></i> Remove Node
        </div>
    </div>

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

</html>

Related