Search code examples
javascriptd3.jsforce-layoutd3-force-directed

How to make d3js force directed graph less shaky when a node is dragged?


I want my force directed graph to stay calm when I drag one node to another point. At the moment, dragging a small node far from the center can cause the whole graph to shake uncontrollably. Which could be a mess since the nodes are plenty (over 100)...

Here is a short video depicting the problem: https://gfycat.com/GleamingMellowHypacrosaurus

I know about the method of setting the coordinate of all the nodes, but it's not possible since the nodes are too many and may increase in later times.

Here is my code:

function getNeighbors(node) {
    return links.reduce(function(neighbors, link) {
        if (link.target.id === node.id) {
            neighbors.push(link.source.id)
        } else if (link.source.id === node.id) {
            neighbors.push(link.target.id)
        }
        return neighbors
    }, [node.id])
}

function isNeighborLink(node, link) {
    return link.target.id === node.id || link.source.id === node.id
}


function getNodeColor(node, neighbors) {
    // If is neighbor
    if (Array.isArray(neighbors) && neighbors.indexOf(node.id) > -1) {
        return 'rgba(251, 130, 30, 1)'
        // return node.level === 1 ? '#9C4A9C' : 'rgba(251, 130, 30, 1)'
    } else {
        // Check the node level
        if (node.level === 0) {
            return '#E72148'
        } else if (node.level === 1) {
            return '#9C4A9C'
        } else {
            return '#D8ABD8'
        }
    }
    //return node.level === 0 ? '#91007B' : '#D8ABD8'
}

function getLinkColor(node, link) {
    return isNeighborLink(node, link) ? 'rgba(251, 130, 30, .85)' : 'rgba(251, 130, 30, 0.25)'
}

function getTextColor(node, neighbors) {
    return Array.isArray(neighbors) && neighbors.indexOf(node.id) > -1 ? '#333' : '#bbb'
}

function getLabelColor(node, link) {
    return isNeighborLink(node, link) ? 'rgba(51, 51, 51, .9)' : 'rgba(51, 51, 51, 0)'    // #333
}

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

var svg = d3.select('svg')
// svg.attr('width', width).attr('height', height)
svg.attr("width", '100%')
    .attr("height", '500px')
    .attr('viewBox', '250 0 800 600')
    //.attr('viewBox','0 0 '+Math.min(width,height)+' '+Math.min(width,height))
    .attr('preserveAspectRatio', 'xMidYMid')
    .append("g")
    .attr("transform", "translate(" + Math.min(width, height) / 2 + "," + Math.min(width, height) / 2 + ")");

//add zoom capabilities
var zoom_handler = d3.zoom()
    .scaleExtent([1 / 2, 8])
    .on("zoom", zoom_actions);

zoom_handler(svg);

function zoom_actions() {
    g.attr("transform", d3.event.transform)
}

function button_zoom_in() {
    zoom_handler.scaleBy(svg, 2);
}

function button_zoom_out() {
    zoom_handler.scaleBy(svg, 0.5);
}

// simulation setup with all forces
var linkForce = d3
    .forceLink()
    .id(function(link) {
        return link.id
    })
    // Alternative: using the distance from the data "strength"
    //.distance(50).strength(function (link) { return link.strength })
    // If don't want to use this, use default here:
    .distance(50).strength(.7)

var simulation = d3
    .forceSimulation()
    .force('link', linkForce)
    .force('charge', d3.forceManyBody().strength(-1500))
    .force('radial', d3.forceRadial(function(d) {
        return d.level * 50
    }, width / 2, height / 2))
    .force('center', d3.forceCenter(width / 2, height / 2))

var dragDrop = d3.drag().on('start', function(node) {
    node.fx = node.x
    node.fy = node.y
}).on('drag', function(node) {
    simulation.alphaTarget(0.7).restart()
    node.fx = d3.event.x
    node.fy = d3.event.y
}).on('end', function(node) {
    if (!d3.event.active) {
        simulation.alphaTarget(0)
    }
    node.fx = null
    node.fy = null
})

function selectNode(selectedNode) {
    var neighbors = getNeighbors(selectedNode)

    // we modify the styles to highlight selected nodes
    nodeElements.attr('fill', function(node) {
        return getNodeColor(node, neighbors)
    })
    textElements.attr('fill', function(node) {
        return getTextColor(node, neighbors)
    })
    linkElements.attr('stroke', function(link) {
        return getLinkColor(selectedNode, link)
    })
    labelElements.attr('fill', function(link) {
        return getLabelColor(selectedNode, link)
    }).attr("style", "-webkit-text-stroke: 1px rgba(255, 255, 255, 0.75); text-shadow: -1px -1px 0 rgba(255, 255, 255, 0.75), 1px -1px 0 rgba(255, 255, 255, 0.75), -1px 1px 0 rgba(255, 255, 255, 0.75), 1px 1px 0 rgba(255, 255, 255, 0.75)")

}

// Format the numbers to dots e.g. 100000 => 100.000
function commafy( num ) {
    var str = num.toString().split('.');
    if (str[0].length >= 5) {
        str[0] = str[0].replace(/(\d)(?=(\d{3})+$)/g, '$1.');
    }
    if (str[1] && str[1].length >= 5) {
        str[1] = str[1].replace(/(\d{3})/g, '$1 ');
    }
    return str.join('.');
}


// Enables zooming
var g = svg.append("g")
    .attr("class", "everything");
// Enables zooming end

// Create circling orbit
var circles = g.selectAll(null) // use g.selectAll instead of svg.selectAll to enable zoom
    .data([200, 350]) // sets the circle radius
    .enter()
    .append("circle")
    .attr("cx", width / 2)
    .attr("cy", height / 2)
    .attr("r", d => d)
    .style("fill", "none")
    .style("stroke", "#ddd");

var linkElements = g.append("g") // use g.append instead of svg.append to enable zoom
    .attr("class", "links")
    .selectAll("line")
    .data(links)
    .enter().append("path")
    .attr("id", function(d, i) {
        return "linkId_" + i;
    })
    .attr("stroke-width", function(link) {
        var linkValueNormalize = link.value / 100;
        var linkValueNormalize = Math.ceil(linkValueNormalize);
        if (linkValueNormalize >= 201) {
            return 26;
        } else if (linkValueNormalize >= 101 && linkValueNormalize <= 200) {
            return 20;
        } else if (linkValueNormalize >= 71 && linkValueNormalize <= 100) {
            return 16;
        } else if (linkValueNormalize >= 41 && linkValueNormalize <= 70) {
            return 12;
        } else if (linkValueNormalize >= 21 && linkValueNormalize <= 40) {
            return 8;
        } else if (linkValueNormalize >= 11 && linkValueNormalize <= 20) {
            return 12;
        } else if (linkValueNormalize >= 7 && linkValueNormalize <= 10) {
            return 8;
        } else if (linkValueNormalize >= 3 && linkValueNormalize <= 6) {
            return 4;
        } else {
            return 2;
        }
        // return linkValueNormalize;
    })
    .attr("stroke", "rgba(251, 130, 30, 0.5)")

var labelElements = g.append("g")
    .attr("class", "label")
    .selectAll("text")
    .data(links)
    .enter().append("text")
    .attr("font-size", 10)
    .attr("font-family", "sans-serif")
    .attr("fill", "rgba(51, 51, 51, 0)")  // #333
    .attr("x", "70")
    .attr("y", "-20")
    .attr("text-anchor", "start")
    .append("textPath")
    .attr("xlink:href", function(d, i) {
        return "#linkId_" + i;
    })
    .text(function(link) {
          var linkValueNormalize = link.value; 
          var linkValueNormalize = commafy(linkValueNormalize);

          return "Rp "+ linkValueNormalize +" M";

    })

var nodeElements = g.append("g") // use g.append instead of svg.append to enable zoom
    .attr("class", "nodes")
    .selectAll("circle")
    .data(nodes)
    .enter().append('a')    // Append a first, then circle
    .attr("xlink:href", function(node){return node.url;})
    .attr("target", "_blank")
    .append("circle")
    .attr("r", function(dat, index, n) {
        var linkItem = links.find(function(link) {
            return link.target == dat.id;
        });

        var radius = 26;
        var linkValueNormalize = (linkItem && linkItem.value) / 100; // in milyar
        var linkValueNormalize = Math.ceil(linkValueNormalize);

        if (linkValueNormalize >= 201) {
            radius = 24;
        } else if (linkValueNormalize >= 101 && linkValueNormalize <= 200) {
            radius = 22;
        } else if (linkValueNormalize >= 71 && linkValueNormalize <= 100) {
            radius = 18;
        } else if (linkValueNormalize >= 41 && linkValueNormalize <= 70) {
            radius = 14;
        } else if (linkValueNormalize >= 21 && linkValueNormalize <= 40) {
            radius = 10;
        } else if (linkValueNormalize >= 11 && linkValueNormalize <= 20) {
            radius = 14;
        } else if (linkValueNormalize >= 7 && linkValueNormalize <= 10) {
            radius = 10;
        } else if (linkValueNormalize <= 6) {
            radius = 6;
        }

        if (dat.level === 0) {
            radius = 26;
        }

        return radius;
    })
    .attr("fill", getNodeColor)
    .attr("stroke", "#fff")
    .attr('stroke-width', 2)
    .call(dragDrop)
    .on('mouseover', selectNode)

var textElements = g.append("g") // use g.append instead of svg.append to enable zoom
    .attr("class", "texts")
    .selectAll("text")
    .data(nodes)
    .enter().append("text")
    .text(function(node) {
        return node.label
    })
    .attr("font-size", 10)
    .attr("font-family", "sans-serif")
    .attr("text-anchor", "middle")
    .attr("fill", "#333")
    .attr("style", "font-weight:bold; -webkit-text-stroke: 1px #fff; text-shadow: 3px 3px 0 #fff, -1px -1px 0 #fff, 1px -1px 0 #fff, -1px 1px 0 #fff, 1px 1px 0 #fff")
    .attr("dx", 0)
    .attr("dy", 20)



simulation.nodes(nodes).on('tick', () => {
    nodeElements
        .attr('cx', function(node) {
            return node.x
        })
        .attr('cy', function(node) {
            return node.y
        })
    textElements
        .attr('x', function(node) {
            return node.x
        })
        .attr('y', function(node) {
            return node.y
        })
    linkElements.attr("d", function(link) {
        return "M" + link.source.x + "," + link.source.y + " L" + link.target.x + "," + link.target.y;
    });
    labelElements
        .attr('x', function(link) {
            return link.target.x
        })
        .attr('y', function(link) {
            return link.target.y
        })
})

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

Solution

  • That's the expected behaviour, since you are restarting the simulation:

    simulation.alphaTarget(0.7).restart()
    

    Besides that, this line should be in the "start" listener, not in the "drag" listener: it makes no sense restarting the simulation several times per second, do you see?

    Back to the question: your desired outcome ("I want my force directed graph to stay calm when I drag one node") is not exactly clear. What is to "stay calm"? However, if I understand it right, you can simply fix all other nodes.

    Since you didn't provided a running code, here is a demo using this example by Mike Bostock. What I did here was just:

    node.each(function(d){
        d.fx = d.x;
        d.fy = d.y;
    })
    

    here is the modified bl.ocks: https://bl.ocks.org/anonymous/93c0c9af8c729b62b1b194841298bc49/ede207278c873aa311133782e9fb70e7504ed622