Skip to content

Graph Creation

DeadlyCoconuts edited this page Jun 13, 2019 · 5 revisions
frontend

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.

Table of Contents

Creation of Nodes, Labels and Edges

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.

Creating the Nodes

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' 
}

Creating the Labels

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'
}

Creating the Edges

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);

Drag and Drop

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;
    })

Simulation

Force Simulation

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));

Object Boundaries

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();
}

Removal of Non-Neighbour Nodes

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
    })
}