2D Graphing with Javascript and the HTML5 Canvas
(March 10, 2021)

Recently Javascript has been my go to for generating visualizations and graphics, it's great being able to share ~5kb HTML files that can be embedded and run on a variety of devices and hardware. In this post I'm going to provide dependancy free code that should help render graphs and visualizations, no JQuery. You can see an example of the graph we will create below, you can also download the finished code here.



This article is split into the following topics:
Setup
Resolution Independant Rendering
Viewport Translation and Scaling
Function Graphing
Grid and Labels
Mobile Support
Retina Display Support



Setup

Begin by creating a text file and give it a .HTML extension, name it whatever you like, and put in the template code below. Anytime we want to run or view our graph just open the HTML file in your preferred browser.
<html>
<meta name="viewport" content="width=device-width,initial-scale=1.0,maximum-scale=1.0,user-scalable=no">
<head>
<style>
body, html {
	margin: 0px;
	padding: 0px;
	overflow: hidden;
	user-select: none;
}
</style>
<script>
//our javascript code will start here
document.addEventListener("DOMContentLoaded",function() {

});
</script>
</head>
<body></body>
</html>

The HTML template sets up for a full screen canvas, the style section disables padding and page scrolling and the meta part is for getting mobile browser rendering to scale correctly. Next shift your attention to the script section, this is where we will be writing our Javascript code. The DOMContentLoaded event is called once the page is loaded and ready for us to initialize DOM elements like the canvas for rendering. Now lets create our canvas and initialize it for 2D rendering.
var canvas, c2d;

//Page load callback, program entry point.
window.addEventListener("DOMContentLoaded", function() {
	//create canvas element and add to body, then get 2d drawing context
	canvas = document.createElement("canvas");
	document.body.appendChild(canvas);
	c2d = canvas.getContext("2d");
	
	//set size
	canvas.width = 400;
	canvas.height = 300;
});

With the 2D canvas context(c2d) setup we can do any 2D rendering we could ever need, the HTML5 Canvas API is incredibly powerful. It's highly suggested you browse the API especially if you want to create your own custom graphs.

Now to create a rendering loop we need to use the requestAnimationFrame function. Below is an example render loop that draws an animation of spinning circles.
function render() {
	var time = Date.now()/1000;//UTC time in seconds

	//clear to black
	c2d.fillStyle = "#000000";
	c2d.fillRect(0,0,canvas.width,canvas.height);

	//rendering spinning circles
	c2d.fillStyle = "#ff00ff";//yellow fill
	c2d.strokeStyle = "#ff0000";//red stroke
	for (var a = 0; a < Math.PI*2; a += 0.5) {
		c2d.beginPath();
		c2d.arc(canvas.width*0.5+Math.cos(a+time)*100, canvas.height*0.5+Math.sin(a+time)*100, 10, 0, Math.PI*2);
		c2d.stroke();
		c2d.fill();
	}
	requestAnimationFrame(render);
}

Call render at the end of DOMContentLoaded and it will begin a loop of queuing frames to be rendered via requestAnimationFrame.



Resolution Independant Rendering

You might have noticed the code above has a fixed canvas size of 400x300, which is simple and easy to set up but has the issue of not working at different sizes. See below as the canvas changes size the spinning circles stay the same so they end up being cut off or too small.


The solution is resolution independant rendering, displaying the same size and aspect regardless of the screen resolution or aspect it's being displayed on. To do this we store all of our actual coordinates and values in what's called 'world space' where space is uniform between X,Y and unrelated to screen resolution. Then whenever we want to render something we use a conversion function to convert world space coordinates to screen space. A simple form of world space would be dividing the x,y by width,height to get the values in a common 0-1 range, this way they work independantly of screen size. This however has aspect scaling issues, things will stretch and distort as width,height become more different. We can solve this by measuring the aspect ratio and taking it into account. Now lets begin by writing a window resize event listener function, this way our canvas will automatically resize itself and in the process we can measure aspect ratio.
var aspectX = 0, aspectY = 0;

//Register window resize event callback.
window.addEventListener("resize",WindowResize);

//Window resize event callback, resize our canvas.
function WindowResize() {
	canvas.width = window.innerWidth;
	canvas.height = window.innerHeight;
	
	//measure aspect ratio
	var sum = canvas.width+canvas.height;
	aspectX = sum/canvas.width;
	aspectY = sum/canvas.height;
}

There are a few different ways to measure and apply aspect ratio scaling, one of the most simple and popular ways is dividing by width or height of the screen. This way has a really nice benefit of always displaying a minimum width/height of the view which allows you to keep things from being cut off, but only on a single axis. For example dividing both coordinates by height will cause Y range to always be -0.5 to 0.5 but X range will be -width/height*0.5 to width/height*0.5. This means X's range isn't bounded to a total of 1 like Y and on a very wide view can extend to many units across. This is popular in a lot of games where many people use wide screens but for our graphs I want to support both wide and tall aspects and have X,Y ranges bounded to 1. Having X,Y ranges bounded to 1 makes it easier to deal with and we can always scale up the field of view afterwards to prevent cutoff. Above I have chosen a method of dividing by the sum of width/height that has this property, I've also chosen to store aspectX,Y inverted to make conversion use a multiply instead of divide.

Now the conversion between world space to screen space is simple. First multiply our world space X,Y coordinate by the aspect X,Y, this will convert it to a uniform scale that is limited to -0.5 to 0.5 range. Next we add 0.5 to shift our -0.5 to 0.5 range to a 0 to 1 range, finally we can scale this by our canvas width,height to get our screen coordinate.
function W2SX(x) {
	return (x*aspectX+0.5)*canvas.width;
}
function W2SY(y) {
	return (y*-aspectY+0.5)*canvas.height;
}

The above functions convert x,y world points to screen points, but what about sizes/scales? For example our arc function in the animation above draws a 10 pixel circle, we need to replace that pixel(screen space) size with a world space size. The above functions won't work properly because of the centering applied by the +0.5, but we can easily remove it to make one as seen below.
function S2S(x) {
	return x*aspectX*canvas.width;
}

Now to modify the arc function call of our rendering code to use world space coordinates converted to screen space.
c2d.arc(W2SX(Math.cos(a+time)*0.15), W2SY(Math.sin(a+time)*0.15), S2S(0.05), 0, Math.PI*2);


And voila, resolution independant rendering.



Viewport Translation and Scaling

With our world space conversion functions we can apply any processing we want to coordinates before rendering. Were going to add a camera/viewport translation and scale allowing you to move and zoom in on areas of the graph. You can see this in the graph at the top of the page, click and drag to move and mouse wheel to zoom.

To begin we need to create variables to store our view position and scale.
var viewportX = 0, viewportY = 0, viewportSize = 5;

Now when converting to screen space there will be an add an extra step of converting world space to camera space. Luckily this is pretty easy, for translation subtracting the camera position from the world position will shift the world space to be centered around the camera. Then dividing by viewportSize will scale our coordinates from the -2.5 to 2.5 range to the -0.5 to 0.5 range. Which gives us our final conversion functions seen below.
/*Convert world position coordinates to screen pixel coordinates.
World space can be moved around and scaled via viewportX,viewportY,viewportScale.
Screen spaces is limited to 0-canvas.width/height and y is flipped going top to bottom.*/
function W2SX(x) {
	return ((x-viewportX)/viewportSize*aspectX+0.5)*canvas.width;
}
function W2SY(y) {
	return ((y-viewportY)/viewportSize*-aspectY+0.5)*canvas.height;
}
//Convert world scale to screen pixel scale.
function S2S(x) {
	return x/viewportSize*aspectX*canvas.width;
}

Above I mentioned scaling up the field of view to prevent cutoff, were going to use the viewportSize to do just that. All we need to do is calculate how much we want to scale, to calculate this we first need to define the size/area we don't want cut off. In the above spinning circle code the circle extends to 0.16 = 0.15(position) + 0.01(radius), multiplying that by 2 to get the full screen size of 0.32. To calculate the scaling we multiply that value by the maximum of aspectX,aspectY, because aspectX,Y are inverted were really calculating the minimum and dividing. Were going to set the viewportSize each time the window is resized, since this will act as a default viewport size were going to store it in a variable named defaultViewportSize. See the example code below.
var defaultViewportSize = 0.32;

function WindowResize() {
	canvas.width = window.innerWidth;
	canvas.height = window.innerHeight;
	
	//measure aspect ratio
	var sum = canvas.width+canvas.height;
	aspectX = sum/canvas.width;
	aspectY = sum/canvas.height;
	
	//scale viewportSize to prevent cutoff
	viewportSize = defaultViewportSize*Math.max(aspectX,aspectY);
}

Now lets add mouse interaction to let you move the viewport. To get dragging we need to manually handle mousedown, mousemove and mouseup events, keeping track of when the mouse is down to see if its dragging. Then the viewport movement is done by converting the movementX,Y variables from the mousemove event to world space, which is done by applying the conversions we did above inverted/backwards. Mouse zoom is just using the mouse wheel scroll event direction to shift the scale. See the code below for handling mouse viewport dragging.
var mouseDown = false;
//mouse drag viewport scrolling
canvas.addEventListener("mousedown", function() {
	mouseDown = true;
});
canvas.addEventListener("mousemove", function(e) {
	if (mouseDown) {
		viewportX -= e.movementX*viewportSize/canvas.width/aspectX;
		viewportY += e.movementY*viewportSize/canvas.height/aspectY;
	}
});
canvas.addEventListener("mouseup", function() {
	mouseDown = false;
});
//mouse scroll wheel viewport zoom
canvas.addEventListener("wheel", function(e) {
	viewportSize += Math.sign(e.deltaY)*viewportSize*0.1;
	e.preventDefault();
});

Now with all this new code, you can interact with the spinning circles below and are able to drag and zoom via mouse.



Function Graphing

There's a variety of ways to plot the results of a function, we're going to start with the easiest a point plot. In the above animation we already learned how to draw points or circles, now we just need to draw them at the positions of the function we want to graph. Our function will take in a X coordinate and return a Y coordinate. To draw the function we will run a for loop over X coordinates, sampling the function to get the corresponding Y value and drawing a point for each sample. See the code below for an example, which graphs the function 'func'.
//graph goes -1 to 1, total of ~2 viewport size
defaultViewportSize = 2;

//our function to graph
function func(x) {
	return Math.sin(x*4);
}

//render loop
function render() {
	//clear to black
	c2d.fillStyle = "#000000";
	c2d.fillRect(0,0,canvas.width,canvas.height);

	//render point graph
	c2d.fillStyle = "#ffffff";//white fill color
	
	//loop through x coordinate range -1 to 1
	for (var x = -1; x < 1+1e-8; x += 0.05) {
		var y = func(x);//sample y coordinate from our function 'func'
		
		c2d.beginPath();
		c2d.arc(W2SX(x),W2SY(y),S2S(0.05), 0,Math.PI*2);//draw point at x,y
		c2d.fill();
	}
	
	requestAnimationFrame(render);
}



A point plot can be easily connected into lines to form a line graph by replacing the arc function calls with lineTo and stroke instead of fill. Animating it isn't difficult either, adding time to our sine waves X coordinate will make it animated and move over time.
//render loop
function render() {
	//UTC time in seconds
	var time = Date.now()/1000;

	//clear to black
	c2d.fillStyle = "#000000";
	c2d.fillRect(0,0,canvas.width,canvas.height);

	//render line graph
	c2d.strokeStyle = "#ffffff";//white line color
	c2d.lineWidth = S2S(0.02);
	
	//loop through x coordinate range -1 to 1
	c2d.beginPath();
	for (var x = -1; x < 1+1e-8; x += 0.05) {
		var y = func(x+time);//sample y coordinate from our function 'func'
		
		c2d.lineTo(W2SX(x),W2SY(y));//add point to line path
	}
	
	//stroke line path
	c2d.stroke();
	
	requestAnimationFrame(render);
}



The final graph format we will cover is a bar graph, it require's a little extra work of shifting the x and y coordinates to create left, right, width, height rectangle bounds.
//render loop
function render() {
	//UTC time in seconds
	var time = Date.now()/1000;

	//clear to black
	c2d.fillStyle = "#000000";
	c2d.fillRect(0,0,canvas.width,canvas.height);

	//render bar graph
	c2d.fillStyle = "#ffffff";//white rectangle color
	
	//loop through x coordinate range -1 to 1
	for (var x = -1; x < 1; x += 0.05) {
		var y = func(x+time*0.1);//sample y coordinate from our function 'func'
		
		c2d.fillRect(W2SX(x),//left corner
					W2SY(y),//top corner
					S2S(0.055),//width
					S2S(y-(-1)));//height is y distance from bottom which is -1
	}
	
	requestAnimationFrame(render);
}




Grid and Labels

What is a graph without a grid and axis coordinates? To draw a grid and labels loop through the coordinate range(-1 to 1), use fillRect to draw lines and fillText to draw text labels along each axis. Below you can see the code.
//pad viewport a little to give room for labels
defaultViewportSize = 2.2;

var gwidth = S2S(0.005), gleft = W2SX(-1), gright = W2SX(1), gbottom = W2SY(-1), gtop = W2SY(1),//grid rectangle as screen coordinates
fsize = S2S(0.05);//label font size

c2d.font = Math.round(fsize)+"px sans-serif";//text font
for (var x = -1; x < 1+1e-8; x += 0.1) {
	c2d.fillStyle = "#ffffff";//color text white
	c2d.fillText(x.toFixed(1),W2SX(x),gbottom+fsize);//draw coordinate label below grid

	c2d.fillStyle = "#202020";//color lines gray
	c2d.fillRect(W2SX(x)-gwidth*0.5,gbottom-gwidth*0.5,gwidth,gtop-gbottom);//fill vertical lines
}
for (var y = -1; y < 1+1e-8; y += 0.1) {
	c2d.fillStyle = "#ffffff";//color text white
	c2d.fillText(y.toFixed(1),gleft-fsize*2,W2SY(y));//draw coordinate label left of grid

	c2d.fillStyle = "#202020";//color lines gray
	c2d.fillRect(gleft-gwidth*0.5,W2SY(y)-gwidth*0.5,gright-gleft,gwidth);//fill horizontal lines
}

Add the grid rendering code before the bar graph and view the beauty that a few lines and text provide.



Mobile Support

Mobile support will require adding drag to pan and pinch to zoom support via touch events, touchstart, touchmove and touchend. Panning(translating) the camera is done by adding the touch movement to viewportX/Y, pinching is a bit more difficult you need to calculate the scale difference between 2 fingers and change viewportSize by that. Below you can see the code required.
//touch viewport scrolling and pinch zoom
window.addEventListener("touchstart", function(e) {
	if (!document.fullscreenElement) {
		if (document.body.requestFullscreen) document.body.requestFullscreen();
	}
	touches = e.touches;
	e.preventDefault();
	e.stopPropagation();
});
window.addEventListener("touchmove", function(e) {
	if (touches.length > 1) {
		//zooming, change viewport size by scale difference of 2 fingers
		var x1 = e.touches[0].clientX-e.touches[1].clientX, y1 = e.touches[0].clientY-e.touches[1].clientY,
			x2 = touches[0].clientX-touches[1].clientX, y2 = touches[0].clientY-touches[1].clientY,
			s1 = Math.sqrt(x1*x1+y1*y1),
			s2 = Math.sqrt(x2*x2+y2*y2);
		if (s1 && s2 && s1 !== s2) viewportSize *= s2/s1;
	} else {
		//panning, change viewport offset by finger movement
		viewportX -= (e.touches[0].clientX-touches[0].clientX)*viewportSize/canvas.width/aspectX;
		viewportY += (e.touches[0].clientY-touches[0].clientY)*viewportSize/canvas.height/aspectY;
	}
	touches = e.touches;
	e.preventDefault();
	e.stopPropagation();
});
window.addEventListener("touchend", function(e) {
	touches = e.touches;
	e.preventDefault();
	e.stopPropagation();
});

The preventDefault and stopPropagation calls at the end of each event are crucial, they prevent the default mobile pinch zoom and scrolling.



Retina Display Support

If your on a system with DPI scaling enabled the above graphs might have been blurry, we can fix this by making our app DPI aware. On Apple Retina and DPI scaled diaplays 1 pixel unit != 1 pixel, window.innerWidth/window.innerHeight aren't the true resolution and are instead divided by a scaling value. This scaling value can be accessed via window.devicePixelRatio, we can fix the blur by scaling up the canvas size and downsampling it using css. Modify the WindowResize function like below.


And finally because the canvas pixels no longer match 1:1 to window pixels, our mouse/touch movements need to be scaled from window pixels to canvas pixels. Add a multiply by pixelScaling to the mousemove/touchmove callbacks as seen below.



Thanks for Reading!
If you have any questions feel free to reach out to me on Twitter or Discord.

XALOEZ.com