Using node.js To Show Our jQuery Chart in Older Browsers

Graham Murray / Thursday, October 4, 2012

Overview

Some of the new data visualization controls in our jQuery product use the HTML5 Canvas control to do their rendering. This allows for some high performance pure client-side rendering, but is unfortunately only supported in browsers that support the HTML5 Canvas control.
The HTML5 Canvas control is supported in all modern browsers and is definitely a large part of the future of doing interactive graphics on the web, but what options do we have to at least provide some of the chart functionality when it isn’t available (e.g. IE 8 or earlier)?
Our jQuery Chart widget (igDataChart) actually does have a function that you can call to export an image (called, naturally, exportImage) but this still relies on the chart having been able to render some content, which, in turn, requires the canvas control. What this blog post will show, is one way to run the igDataChart on the server, and capture an image to display on clients that do not support Canvas. We will:

  • Configure the chart to seamlessly request an image from the server if it detects that Canvas is not available, providing the same configuration and data that we just tried to use to initialize it.
  • Install and configure node.js on Windows and some required packages.
  • Write a simple node.js server that can generate chart images using igDataChart for the provided chart configuration and data.

Why node.js?

The goal here is to run the chart (which is JavaScript code) on the server, get an image from it, and send that back down to the client. There is an added complication that the chart:

  • Is configured using the jQuery UI widget framework. So depends on jQuery UI, and hence jQuery
  • Manipulates some DOM
  • Uses Canvas.

If it weren’t for those three bullet points there would be all sorts of ways to run its JavaScript on the server and produce some output, but, as it stands, we need to run the chart’s code in an environment that:

  • Either has or simulates the DOM the chart requires.
  • Can load or partially load jQuery and jQuery UI.
  • Has some support for the Canvas control.

Optionally we would like this to be:

  • Fast
  • Headless

These requirements and optional requirements remove a lot of the options available to us. node.js, is one of the options left, but not the only one. For example, I’m going to investigate the use of Awesomium for this purpose next. But let’s see what we can do with node first!
Node can:

  • Run JavaScript on the server (using the very zippy V8 engine)
  • Can simulate DOM and load jQuery and jQuery UI libraries through the jsdom package.
  • Can simulate the Canvas control through the node-canvas package (among others).
  • Can do all this with not a lot of lines of code.

Note: Because the packages in node.js that support this behavior may have varying degrees of quality, and don’t have the same level of vetting that, say, IE, or Chrome’s DOM/Canvas implementations do, I can’t vouch for the quality of the reproduction of the chart using these tools. But this should give you an idea of how powerful it is that igDataChart is pure JavaScript and thus can be executed on all sorts of platforms. Theoretically you could set up a node environment on a non-windows platform and achieve the same results.

The Client Code

Let’s take a sample html page containing the chart as our starting point:

<!DOCTYPE html>
<html>
<head>
    <title></title>
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
   
    <script src="http://ajax.googleapis.com/ajax/libs/jquery/1.6.4/jquery.min.js"></script>
	<script src="http://ajax.googleapis.com/ajax/libs/jqueryui/1.8.7/jquery-ui.min.js"></script>
    <script  type="text/javascript" src="js/infragistics.loader.js"></script>
    
    <script id="tooltipTemplate1" type="text/x-jquery-tmpl">
        <div id="tooltip">
            <span id="tooltipValue">Value: ${item.Value1}</span>
        </div>
    </script>

    <script id="tooltipTemplate2" type="text/x-jquery-tmpl">
        <div id="tooltip">
            <span id="tooltipValue">Value: ${item.Value2}</span>
        </div>
    </script>

    <script type="text/javascript">
        $.ig.loader({
            scriptPath: "js/",
            cssPath: "css/",
            resources: "igDataChart.Category"
        });

        $.ig.loader(function () {
            var currData, currDataSource, doGeneration;

            currData = [
				{ Label: "A", Value1: 1, Value2: 2 },
				{ Label: "B", Value1: 2, Value2: 4 },
				{ Label: "C", Value1: 3, Value2: 3 },
				{ Label: "D", Value1: 4, Value2: 4 },
				{ Label: "E", Value1: 2, Value2: 1 },
				{ Label: "F", Value1: 2, Value2: 2 },
				{ Label: "G", Value1: 3, Value2: 2 },
				{ Label: "H", Value1: 1, Value2: 3 }
			];

			var initOptions = {
                width: "700px",
                height: "400px",
                dataSource: currData,
				axes: [{
                    name: "xAxis",
                    type: "categoryX",
                    label: "Label"
                },
                {
                    name: "yAxis",
                    type: "numericY"
                }],
                series: [{
                    name: "series1",
                    type: "line",
                    xAxis: "xAxis",
                    yAxis: "yAxis",
                    valueMemberPath: "Value1",
                    showTooltip: true,
                    tooltipTemplate: "tooltipTemplate1",
                    thickness: 5
                }, {
                    name: "series2",
                    type: "line",
                    xAxis: "xAxis",
                    yAxis: "yAxis",
                    valueMemberPath: "Value2",
                    showTooltip: true,
                    tooltipTemplate: "tooltipTemplate2",
                    thickness: 5
                }],
                horizontalZoomable: true,
                verticalZoomable: true,
                windowResponse: "immediate"
            }
			
            $("#chart").igDataChart(initOptions);
        });
    </script>
</head>
<body>
    <div id="chart"></div>
</body>
</html>

I won’t delve into what is contained here as it is discussed thoroughly in prior blog posts and our help documentation. If you load it in any modern browser, you’ll get something that looks like this:

But if you load it in, say, IE8, you currently get this:

This is because IE9 is the first version of Internet Explorer that supports the HTML5 Canvas element. If you want to see this effect, but have IE9 or later installed, open the F12 developer tools, and then select DocumentMode: IE7 standards. This will cause the chart to think that the Canvas element is no longer available.

Implementing Client Fallback

So how do we detect the lack of canvas and provide some fallback logic for this case? Luckily, the chart raises an event when it detects canvas isn’t present and is about to throw up that unsupported browser splash that you see above.
Let’s change the chart definition in the above page so that it looks like this:

$.support.cors = true;
			var initOptions = {
                width: "700px",
                height: "400px",
                dataSource: currData,
				axes: [{
                    name: "xAxis",
                    type: "categoryX",
                    label: "Label"
                },
                {
                    name: "yAxis",
                    type: "numericY"
                }],
                series: [{
                    name: "series1",
                    type: "line",
                    xAxis: "xAxis",
                    yAxis: "yAxis",
                    valueMemberPath: "Value1",
                    showTooltip: true,
                    tooltipTemplate: "tooltipTemplate1",
                    thickness: 5
                }, {
                    name: "series2",
                    type: "line",
                    xAxis: "xAxis",
                    yAxis: "yAxis",
                    valueMemberPath: "Value2",
                    showTooltip: true,
                    tooltipTemplate: "tooltipTemplate2",
                    thickness: 5
                }],
                horizontalZoomable: true,
                verticalZoomable: true,
                windowResponse: "immediate",
				browserNotSupported: function () {
					initOptions.dataSource = currData;
					var postData = JSON.stringify(initOptions);
					var postObj = { 'initOptions' : postData }
					
					$.ajax({ 
						type: 'POST',
						url: 'http://localhost:1337',
						data: postObj,
						dataType: 'text',
						success: function (data) {
							$("#chart").append($("<img />").attr('src', data));
						},
						error: function(error) {
							alert(error);
						}});
					
					return false;
				}
            }

And to the scripts section add:

<script  type="text/javascript" src="js/json2.js"></script>

So what have we done? We have:

  • Added a reference to the json2.js library (grab from the web if you don’t have).
    • We need to do a json serialize to send the settings for the chart to the server, and earlier versions of IE did not have JSON.stringify built in.
  • Added the line: $.support.cors = true;
    • Since our node server is on a different port (but same host), we need to tell jQuery that our browser is going to allow cross origin resource sharing, or it will give us an error when we try to post to a different port. If everything were running on the same port (for example, if you were using node to serve the page, itself, or if you were using a proxy service to talk to node) this would not be necessary.
    • This seems to be sufficient for older versions of IE as they don’t seem to treat a different port as a cross site access that they need to block. For older versions of other browsers that would consider a port change a same origin violation we will either have to set up the correct CORS headers or create a proxy on the same port as the web page to route traffic back and forth to node. 
  • Added a handler for the event: browserNotSupported on the chart
    • This event fires when the chart can’t find the canvas element. We can return false and it will not attempt to build the splash that would notify the user of this. Here is also where we are using jQuery to POST the serialized configuration for the chart (all the settings we were trying to use) to the node server we are about to write that will return the URL for where it has stored the resulting chart image. When the result comes back, we add the image to the container that would have contained the chart.

If you were to run this modified version now, it will still work in IE9+ and other modern browsers, but will fail for older browsers as we have not yet implemented the node server!

Enter node.js

First, we have some prerequisites. Please note that these are all for Windows. For other platforms you would need to get the prerequisites installed via other methods. The prerequisites are:

  • Install node
  • Install Python 2.7.3
    • Some of the packages require this.
    • Install from here: http://www.python.org/download/
    • Be sure to pick the x86 – 64 version if you installed the 64 bit version of node (I think this matters…)
  • Install GTK+
    • This is required for the node-canvas package.
    • If you installed the 64-bit node, you need the 64 bit version
    • Download the “All-in-one” bundle for 32-bit or 64-bit and unzip the contents to C:\GTK
  • Make sure you have Visual Studio 2010 installed (if using node on windows). Some of the node packages seem to compile stuff during install and require the c++ compiler bits be present.
  • Install jsdom
    • Open the node command line and type: npm install jsdom
  • Install node-canvas
    • Open the node command line and type: npm install canvas

And those are the pre-requisites. If you have issues getting any of them to install, double check my instructions (I had an error installing node-canvas until I had the right GTK version), or visit the websites for those node packages, as they can probably assist you better than I in that regard.

Creating the Chart Image Server

Now that we have node setup and the pre-requisite dependencies and packages installed, let’s set up the node server. First, create an empty JavaScript file. I called it chart2Image.js, and put it in c:/node so as to remember the path easily.
Then, add this code:

var http = require('http');
var qs = require('querystring');
var jsdom = require('jsdom');
var fs = require('fs');
var canvas = require('canvas');

Here we are loading the various modules we will need in order to run the server. We, of course, need http and querystring since we are creating a server and must be able to extract our post data from the request. We include the jsdom module so that we can load the chart and its required DOM environment, fs so that we can save the resulting image to disk, and canvas because the chart requires this component to use for rendering.
Now add this code:

var imageIndex = 0;
var chartScriptsDir = "C:/inetpub/wwwroot/fallback/js/";
var imageDir = "C:/inetpub/wwwroot/fallback/chartImages/";
var chartScripts = [ 
	'http://ajax.googleapis.com/ajax/libs/jquery/1.6.4/jquery.min.js',
	'http://ajax.googleapis.com/ajax/libs/jqueryui/1.8.7/jquery-ui.min.js',
	chartScriptsDir + 'modules/infragistics.util.js',
	chartScriptsDir + 'modules/infragistics.dv.core.js',
	chartScriptsDir + 'modules/infragistics.chart_categorychart.js',
	chartScriptsDir + 'modules/infragistics.datasource.js',
	chartScriptsDir + 'modules/infragistics.dvcommonwidget.js',
	chartScriptsDir + 'modules/infragistics.ui.chart.js' ];

We are going to use a simple counter to make sure we have a unique image name for each chart image we generate. A more complicated solution might take some sort of hash of the incoming chart settings so as to try and cache chart images for reuse. But for simplicity’s sake, we are going to use a simple counter.
Here we are also capturing the various JavaScript files we need to load from disk and also were we are going to put the output images. These directories are all based around c:/inetpub/wwwroot/fallback, which is simply where I put the page that we declared earlier, which has the latest build of our jQuery product in js and css subfolders.
Now we add:

var window = null;
var $ = null;	

We are going to hold onto the window object and jQuery that will get loaded, upon the first request, by jsdom, so that we don’t have to initialize all of that for every request.
Now we add:

function renderChart(settings, response) {
	var chartContainer, img, dataUri, data, buffer, fileName, currentIndex;

	$("body").children().remove();
	
	chartContainer = $("<div id='chart'></div>");
	$("body").append(chartContainer);
	
	chartContainer.igDataChart(settings);
	chartContainer.igDataChart('flush');
	img = chartContainer.igDataChart('exportImage');
	dataUri = img.src;
	data = dataUri.replace('data:image/png;base64,', '');
	
	buffer = new Buffer(data, 'base64');
	currentIndex = imageIndex++;
	fileName = imageDir + currentIndex + '.png';
	fs.writeFile(fileName, buffer, function (err) {
		if (err) {
			//todo: indicate an error occurred.
			response.end();
		} else {
			response.write("chartImages/" + currentIndex + '.png');
			response.end();
		}
	});
}

This method, given the cached jQuery and window, the chart settings (including data) and response object:

  • Removes any existing chart from the DOM
  • Creates a new div to hold the chart.
  • Initializes the chart with the provided settings sent from the client.
  • Calls flush on the chart to cause it to finish any asynchronous rendering work.
  • Asks the chart to export an image of its content.
  • Extracts the data url from the returned image.
  • Creates a base64 encoded buffer from the data url.
  • Saves the image to disk in the directory of the web site that will load it.
  • Writes the url path to the image to the http response object to notify the client where to find the generated image.


Now that we have a method to create the chart image, lets create the actual server:
Add the following to complete the file:

http.createServer(function (request, response) {

	if (request.method == 'POST') {
		var body = '';
		request.on('data', function (data) {
			body += data;
		});
		
		request.on('end', function () {
			var postData = qs.parse(body);
			
			var settings = JSON.parse(postData.initOptions);
			
			if (window === null) {
				jsdom.env(
				"<html><head></head><body><div id='chart'></div></body></html>",
				chartScripts,
				function(errors, win) {
					window = win;
					$ = window.jQuery;
					
					window.Canvas = canvas;
					window.console.log = console.log;
					
					//begin polyfills for lack of support in JSDOM
					canvas.prototype.style = {};
					$.attrHooks.width = {
						set: function (elem, value) {
							if (elem instanceof canvas) {
								elem.width = parseInt(value);
								elem.setAttribute("width", "" + value);
								return value;
							}
						}
					}
					$.attrHooks.height = {
						set: function (elem, value) {
							if (elem instanceof canvas) {
								elem.height = parseInt(value);
								elem.setAttribute("height", "" + value);
								return value;
							}
						}
					}
					$.attrHooks.offsetHeight = {
						get: function (elem, value) {
							//note, probably better to have the client send this,
							//plus its font settings so that we can match them.
							return "12";
						}
					}
					//end polyfills for lack of support in JSDOM
					
					renderChart(settings, response);
				});
			} else {
				renderChart(settings, response);
			}
		});
	}
}).listen(1337);

console.log("started");

Here we:

  • Get node to create an http server, listening on port 1337.
  • We are expecting a POST request, since the chart initialization json could be much larger than the maximum query string size. 
  • The POST request contains the JSON serialization of the chart settings, so we deserialize it.
  • If the window isn’t null we continue to renderChart which we defined above.
  • If window is null, it means we haven’t initialized it yet, so it’s time to talk to jsdom.
  • We ask jsdom to create us a simple page loading all of the scripts we definied up top and call us back when ready.
  • We cache the window and jQuery objects and inject the Canvas implementation into the window so the chart will create this implementation when needed.
  • We inject some polyfills into jQuery to deal with the fact that some functionality that the chart needs is missing from jsdom/node-canvas.
  • We call renderChart to render the chart to disk and return the url.

The polyfills are needed because at various points the chart will resize a Canvas by doing something along the lines of canvas.attr(“width”, 50), but this doesn’t flow properly into the node-canvas implementation without some help. Also jsdom doesn’t seem to implement offsetHeight, which the chart uses for some text height estimation.
Now, you should be able to run the server:

  • node c:/node/chart2Image.js
  • started


And then load the page that we created at the beginning in ie7 document mode, and voila!



You should see an image that looks roughly the same as in ie9 mode. Why is it slightly different? We could have made it exactly the same, but I:

  1. wanted to explain why the difference exists
  2. wanted you to be able to more easily tell the difference between the node generated chart and the client side one.

So why the difference? We don’t currently have the server side render loading the chart’s css files. The chart has an internal color palette that will be overridden if you include the chart’s css. The server chart is using the internal palette, but the client chart is using the palette from the css. Also, you will note that the font size is different. This is due to different default fonts in jsdom vs the client browser.
You could normalize all this by setting the palette and fonts in the chart config, or getting the chart css to load on the server, etc. But I’ll leave that for another day. Next up, let’s see if we can achieve the same thing with Awesomium.

You can download the code for this sample here.

Note: You would really want some extra handling in the node server to deal with abusive or non-standard requests, but I wanted to keep the sample pretty straightforward. Also, if you are using features of the chart that aren't easily serializable, you would need to do a bit more work to make sure that this initiliazation data was also accessible to the server (for example, custom marker templates).


-Graham