-
Notifications
You must be signed in to change notification settings - Fork 0
Graph Creation
The application uses the Javascript library D3.js to create an interactive force-directed graph. The graph is defined by the list of nodes and edges nodeList
and linkList
that we have derived earlier from Web Scraping.
To begin with any graph, we would need some nodes, labels and edges (the text that accompany the nodes). This is easily done using the data objects nodeList
and linkList
that we have derived earlier. Note that in script.js
, these 2 lists are named as nodes
and links
.
What essentially happens is that a node, in this case represented solely by a circle, will be created for each item (or Wikipedia page) found in nodes
.
nodeElements = nodeGroup
.selectAll('circle')
.data(nodes, node => node.id); // binds circles to nodes created
nodeElements.exit().remove(); // selects and removes elements that need to be removed
const nodeEnter = nodeElements
.enter()
.append('circle') // creates circles for nodes without a circle element
.attr('r', radius)
.attr('fill', node => getNodeColor(node))
.attr('stroke', node => d3.rgb(getNodeColor(node)).darker())
.call(dragDrop)
.on('click', selectNode);
nodeElements = nodeEnter.merge(nodeElements);
Each circle is defined by certain attributes: for example, they all have the same radius but are coloured differently depending on their node level. The source node is always green, whereas the other nodes are by default grey, except when they are being selected, or are neighbours of a selected node:
function getNodeColor(node, neighbors) {
if (neighbors instanceof Array && neighbors.indexOf(node.id) > -1) {
return node.level === 0 ? '#e1fb39' : 'orange'
}
return node.level === 0 ? '#a6fb39' : 'gray'
}
Similarly, for each node we would like to create a label to describe it. This would preferably be the title of the page that the node represents. Furthermore the position of each label is set to accompany the node that they describe:
textElements = textGroup
.selectAll('text')
.data(nodes, node => node.id);
textElements.exit().remove();
const textEnter = textElements
.enter()
.append('text')
.text(node => node.label)
.attr('font-size', 7)
.attr('dx', 15) // defines position of label
.attr('dy', 4) // defines position of label
.attr('fill', node => getTextColor(node, []));
textElements = textEnter.merge(textElements);
Once again, the colour of each label would depend on its node's colour. The function getTextColor()
is used to achieve this:
function getTextColor(node, neighbors) {
if (node.level == 0) {
return neighbors.indexOf(node.id) > -1 ? '#e1fb39' : '#a6fb39'
}
return neighbors.indexOf(node.id) > -1 ? 'orange' : 'white'
}
Last but not least, we need to create edges, each represented by a line, from the links
list to represent the connections between the nodes.
linkElements = linkGroup
.selectAll('line')
.data(links, link => {
return link.target.id + link.source.id
});
linkElements.exit().remove();
const linkEnter = linkElements
.enter()
.append('line')
.attr('stroke-width', 1)
.attr('stroke', '#E5E5E5');
linkElements = linkEnter.merge(linkElements);
The graph created includes a drag and drop feature which allows the simulation to continuously run even when the node selected is being dragged.
var dragDrop = d3.drag()
.on('start', node => {
node.fx = node.x;
node.fy = node.y;
})
.on('drag', node => {
simulation.alphaTarget(0.2).restart();
node.fx = d3.event.x;
node.fy = d3.event.y;
})
.on('end', node => {
if (!d3.event.active) {
simulation.alphaTarget(0)
}
node.fx = null;
node.fy = null;
})
In order to create a force to simulate the repulsive and attractive forces between the nodes, we'll use the D3 method forceSimulation()
and add additional forces to it:
var simulation = d3
.forceSimulation() // creates force simulation instance
.force('charge', d3.forceManyBody().strength(-120)) // creates repulsive effects between nodes
.force('center', d3.forceCenter(width/2, height/2)); // creates automatic re-centering effects
simulation.force('link', d3.forceLink() // adds link force to simulation
.id(link => link.id)
.strength(link => link.strength));
As the simulation runs, the nodes, edges and labels all move around freely on the browser window. However, in certain cases you may wish to restrict the movement of the nodes to be solely within the window to prevent the nodes from flying off-screen. Doing so however might create overcrowding on the screen should there be too many nodes in the graph. This can be accomplished by commenting out the relevant portions of the function call:
simulation.nodes(nodes).on('tick', () => {
/*
// Use this to prevent nodes from flying out of bounds
nodeElements
.attr("cx", node => Math.max(radius, Math.min(width - radius, node.x)))
.attr("cy", node => Math.max(radius, Math.min(height - radius, node.y)));
*/
// Use this to let nodes fly around freely
nodeElements
.attr("cx", node => node.x)
.attr("cy", node => node.y);
textElements
.attr("x", node => node.x)
.attr("y", node => node.y);
linkElements
.attr('x1', link => link.source.x)
.attr('y1', link => link.source.y)
.attr('x2', link => link.target.x)
.attr('y2', link => link.target.y);
});
simulation.force('link').links(links);
simulation.alphaTarget(0.2).restart();
}
To show only the neighbours of the node selected by the user selectedNode
, we need to remove all the non-neighbours of the newly selected node from our initial nodes
list and add to this list all of selectedNode
's neighbours:
function updateData(selectedNode) {
const neighbors = getNeighbors(selectedNode);
const newNodes = baseNodes.filter(node => {
return neighbors.indexOf(node.id) > -1 || node.level === 1 // filter out nodes that are not neighbors; keeps neighboring nodes
})
const difference = {
// to be removed
removed: nodes.filter(node => newNodes.indexOf(node) === -1), // filter out non-new neighbor nodes -> to be removed
// to be added
added: newNodes.filter(node => nodes.indexOf(node) === -1) // filter out new neighboring nodes that are not currently present
}
difference.removed.forEach(node => nodes.splice(nodes.indexOf(node), 1));
difference.added.forEach(node => nodes.push(node));
links = baseLinks.filter(link => {
return link.target.id === selectedNode.id || link.source.id === selectedNode.id
})
}