DEV Community

Cover image for Drawing interactive graphs with canvas and javascript
Tom Holloway πŸ•
Tom Holloway πŸ•

Posted on

Drawing interactive graphs with canvas and javascript

At my work we monitor network operations and infrastructure through a variety of tools like SNMP, NetFlow, Syslog...etc. One of the ways to help customers figure out what is going on in their networks is to visualize it through graphs! There are a number of great libraries to do this but the main one that I use quite often is d3.js.

D3

But this isn't a post about d3 (that's for another day), it's about utilizing Canvas to draw stuff on the screen. More specifically, we want to draw a series of connected nodes in a graph and be able to drag these nodes around. Let's get started!

Drawing nodes

First thing we will need to do is setup our canvas.

<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8">
    <title>Map</title>
    <link rel="stylesheet" href="index.css">
    <script defer type="text/javascript" src="load.js"></script>
</head>
<body>
    <canvas></canvas>
</body>
</html>
/** index.css */
:root {
    --root-font-size: 12px;
    --bg: #fafafa;
}

/** Reset */
html, body, nav, ul, h1, h2, h3, h4, a, canvas {
    margin: 0px;
    padding: 0px;
    color: var(--text-color);
}
html, body {
    font-family: Roboto, -apple-system, BlinkMacSystemFont, 'Segoe UI', Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
    font-size: var(--root-font-size);
    background: var(--bg);
    height: 100%;
    width: 100%;
    overflow: hidden;
}
*, body, button, input, select, textarea, canvas {
    text-rendering: optimizeLegibility;
    -webkit-font-smoothing: antialiased;
    -moz-osx-font-smoothing: grayscale;
    outline: 0;
}

And now our javascript ⬇️ We're going to start off by keeping around an array of nodes that we want to draw. A node will consist of an x, y, radius, fill, stroke. These properties will correspond to canvas api methods when we go to draw them.

const canvas = document.querySelector('canvas');
const context = canvas.getContext('2d');

var nodes = [];

function resize() {
    canvas.width = window.innerWidth;
    canvas.height = window.innerHeight;
}

window.onresize = resize;
resize();

Let's go ahead and add our drawNode function right now. We're going to use the arc function to draw at a point, radius and angles for the circle. We also manipulate the rendering context for the fill, stroke. Since we are generating a circle with the arc, we want the entire shape to be encapsulated in a Path this is why we are using the beginPath function.

function drawNode(node) {
    context.beginPath();
    context.fillStyle = node.fillStyle;
    context.arc(node.x, node.y, node.radius, 0, Math.PI * 2, true);
    context.strokeStyle = node.strokeStyle;
    context.stroke();
    context.fill();
}

Mouse Functions

Since we want this to be interactive, let's add the ability to track when the user touches or clicks on the canvas and draw the node right there at the cursor position.

function click(e) {
    let node = {
        x: e.x,
        y: e.y,
        radius: 10,
        fillStyle: '#22cccc',
        strokeStyle: '#009999'
    };
    nodes.push(node);
    drawNode(node);
}

window.onclick = click;

Drawing Nodes

Great! Now we have some nodes drawn to the screen but we don't have any way to move them around. Let's take advantage of the target position on the mouseDown function so we can move things around with mouseMove.

var selection = undefined;

function within(x, y) {
    return nodes.find(n => {
        return x > (n.x - n.radius) && 
            y > (n.y - n.radius) &&
            x < (n.x + n.radius) &&
            y < (n.y + n.radius);
    });
}

function move(e) {
    if (selection) {
        selection.x = e.x;
        selection.y = e.y;
        drawNode(selection);
    }
}

function down(e) {
    let target = within(e.x, e.y);
    if (target) {
        selection = target;
    }
}

function up(e) {
    selection = undefined;
}

window.onmousemove = move;
window.onmousedown = down;
window.onmouseup = up;

Drawing Bug

Bug Fixes

Dragging causes the nodes to be rendered over and over

Uh oh! We need to fix this so we re-render all the nodes whenever this happens. To do this, we just need to add a bit of clearRect to the draw code and instead of drawNode we'll just call it draw.

function click(e) {
    let node = {
        x: e.x,
        y: e.y,
        radius: 10,
        fillStyle: '#22cccc',
        strokeStyle: '#009999'
    };
    nodes.push(node);
    draw();
}

function move(e) {
    if (selection) {
        selection.x = e.x;
        selection.y = e.y;
        draw();
    }
}

function draw() {
    context.clearRect(0, 0, window.innerWidth, window.innerHeight);
    for (let i = 0; i < nodes.length; i++) {
        let node = nodes[i];
        context.beginPath();
        context.fillStyle = node.fillStyle;
        context.arc(node.x, node.y, node.radius, 0, Math.PI * 2, true);
        context.strokeStyle = node.strokeStyle;
        context.fill();
        context.stroke();
    }
}

Clicking and dragging can create a duplicate node

This works pretty well but the problem is if we click too quickly the nodes will appear when we mousedown and then move. Let's instead rely on the move event to clear the state when we want to create a new node.

We will get rid of the window.onclick and click code and instead rely on the mousedown, mouseup, mousemove events to handle selection vs create states. When the mouseup event occurs, if nothing is selected and it hasn't yet been moved then create a new node.

/** remove the onclick code and update move and up code */
function move(e) {
    if (selection) {
        selection.x = e.x;
        selection.y = e.y;
        selection.moving = true;
        draw();
    }
}

function up(e) {
    if (!selection || !selection.moving) {
        let node = {
            x: e.x,
            y: e.y,
            radius: 10,
            fillStyle: '#22cccc',
            strokeStyle: '#009999',
            selectedFill: '#88aaaa'
        };
        nodes.push(node);
        draw();
    }
    if (selection) {
        delete selection.moving;
        delete selection.selected;
    }
    selection = undefined;
    draw();
}

Selected Drag

Great! Note, if you update the draw code to key off of the selected state you can change the fill like so:

context.fillStyle = node.selected ? node.selectedFill : node.fillStyle;

Adding Connections

The next thing we are going to do is at some edges to this graph. We want to be able to connect a line from one node to another. To do this, we're going to use a simple line for now and have an edges array defining these connections.

The behavior we want to accomplish is:

  • mousemove, if there is a selection and the mouse is currently down ➑️ update selection x and y
  • mousedown, find the node target, if there is a selection clear the selected state, then assign the selection to the target and set its selected state and draw
  • mouseup, if there is no selection then create a new node and draw, otherwise if the current selection is not selected (because of mouse down) then clear the selection and draw after
  • additionally mousedown when the selection changes to a new node and we have something already selected we can create an edge
function move(e) {
    if (selection && e.buttons) {
        selection.x = e.x;
        selection.y = e.y;
        draw();
    }
}

function down(e) {
    let target = within(e.x, e.y);
    if (selection && selection.selected) {
        selection.selected = false;
    }
    if (target) {
        selection = target;
        selection.selected = true;
        draw();
    }
}

function up(e) {
    if (!selection) {
        let node = {
            x: e.x,
            y: e.y,
            radius: 10,
            fillStyle: '#22cccc',
            strokeStyle: '#009999',
            selectedFill: '#88aaaa',
            selected: false
        };
        nodes.push(node);
        draw();
    }
    if (selection && !selection.selected) {
        selection = undefined;
    }
    draw();
}

This is nearly the same result as before, except now we can control selection state. What I would like to happen is that we can add an edge such that the current selection and the new selection creates a new edge and line.

var edges = [];

function draw() {
    context.clearRect(0, 0, window.innerWidth, window.innerHeight);

    for (let i = 0; i < edges.length; i++) {
        let fromNode = edges[i].from;
        let toNode = edges[i].to;
        context.beginPath();
        context.strokeStyle = fromNode.strokeStyle;
        context.moveTo(fromNode.x, fromNode.y);
        context.lineTo(toNode.x, toNode.y);
        context.stroke();
    }

    for (let i = 0; i < nodes.length; i++) {
        let node = nodes[i];
        context.beginPath();
        context.fillStyle = node.selected ? node.selectedFill : node.fillStyle;
        context.arc(node.x, node.y, node.radius, 0, Math.PI * 2, true);
        context.strokeStyle = node.strokeStyle;
        context.fill();
        context.stroke();
    }
}

function down(e) {
    let target = within(e.x, e.y);
    if (selection && selection.selected) {
        selection.selected = false;
    }
    if (target) {
        if (selection && selection !== target) {
            edges.push({ from: selection, to: target });
        }
        selection = target;
        selection.selected = true;
        draw();
    }
}

Graph Nodes

That's it! Now we have some edges between nodes! In a follow-up to this post I will talk about Bezier Curves and how you can create some neat smooth interpolations between those curves what the Canvas api has to offer in terms of functionality here.

Cheers! 🍻


If you liked this article, feel free to give me a follow and a heart/unicorn. Also, if you are so inclined, check out my twitter for similar and other updates!

If you enjoyed this canvas tutorial, check out my other articles on the canvas api below:

Thanks again! πŸ•

Top comments (3)

Collapse
 
hichemkirigaya profile image
Hichem Liebert • Edited

Hello ,first I want to thank you for this awesome work !
I just wanted to point out a little bug ,actually if u draw an edge by selecting two nodes, and then you try to redraw the same edge (by selecting the previous nodes ,either in the same order or reverse order ) ,this will add a different instance of that edge in the edges array .
One way to solve this, is to actually verify if the edge already exists in the edges array .In my case ,I created a function named edgeExists that accepts two parameters (a string representation of the selection and the target using JSON.stringify) , the function loops through the edges array ,for each edge it verifies the edge's ends are actually our selection and target (by comparing JSON.stringify(edge.from /edge.to) to the arguments) ,if so ,then it returns true.

You can find my implementation in the uploaded image.

dev-to-uploads.s3.amazonaws.com/i/...

Collapse
 
ben profile image
Ben Halpern

Cool!

Collapse
 
yamillanz profile image
Yamil Lanz

Nice!!!