Sunday, 28 February 2021

D3 link breaks during special removal case

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>


from D3 link breaks during special removal case

No comments:

Post a Comment