Wednesday, April 13, 2011

Adding Better Ajax/JSON methods to HighCharts

I recently started using Highcharts as the charting options in Crystal Reports are rather limited. Here is a small patch I made to simplify the loading of chart data from JSON server calls via AJAX. This does not do anything incredibly unique, just simplifies the code to do so by adding an ajaxSource property to the chart and series configurations that is loaded on init and a reloadAjax method to automatically reload the data.

05/03/2011 - Rewrote the code as a extension instead of a patch and added support for setting multiple series via options.chart.ajaxSource.

You can set an ajaxSource for an individual series like in this example.
chart = new Highcharts.Chart({
  chart: {
    renderTo: 'container',
    type: 'spline'
  },
  xAxis: {
    type: 'datetime' }
  },
  series: [{
    name: 'Logins',
    data: [],
    ajaxSource: '/Home/GetLoginsOverTime'
  }]
});

In this case the server needs to return a JSON object with aaData in style of the options.series.data property.
{"aaData":[
 {"x":1300406400000,"y":3},{"x":1300410000000,"y":4},{"x":1300413600000,"y":5}
]}


Alternately you can set an ajaxSource for the entire chart that returns multiple series of data. These two styles can also be combined.
chart = new Highcharts.Chart({
  chart: {
    renderTo: 'container',
    type: 'spline',
    ajaxSource: '/Home/GetTableActivity'
  },
  xAxis: {
    type: 'datetime' }
  }
});

In this case the returned JSON object should have an aaData in the style of an array of options.series objects.
{"aaData":[
  {"name":"users","data":[
    {"x":1299888000000,"y":3},{"x":1299891600000,"y":13},{"x":1299895200000,"y":4}]},
  {"name":"messages","data":[
    {"x":1299888000000,"y":4},{"x":1299891600000,"y":33},{"x":1299895200000,"y":5}]}
]}


You can then call chart.reloadAjax(); to reload the chart data without reloading the page. This will reload all ajaxSource's in the chart, it is not possible to only do one series at a time. This is more useful when combined with automatic parameter replacement take this next sample for example.
chart = new Highcharts.Chart({
  chart: {
    renderTo: 'container',
    type: 'spline',
    ajaxSource: '/Home/GetTableActivityByDate?date=#date'
  },
  xAxis: {
    type: 'datetime' }
  }
});
 
Instead of setting a date in code or writing code to manage this we use #date. This will cause the Chart.init() and Chart.reloadAjax() calls to look for an object with the ID date and use that instead when making the AJAX call. Multiple parameters can be combined and reloadAjax can be triggered whenever they change. This input box can be added to the previous example.
<input type='text' id='date' change='chart.reloadAjax()'/>


The Chart.reloadAjax(appendData, shift) method also has two optional parameters that can be passed in. The first is appendData, if this is true the new points will be added to the series in question instead of replacing them. The second is shift, if this is true then as each point is added the oldest point in that same series will be removed.
Note that appendData does not check for duplicates and will corrupt the chart is the same span is reloaded and that shift only works going forward in time not backwards.


Here is a sample bit of server side code that can be loaded via a JSON request. This example is using MVC and the JSON View but you can also return stright JSON from ASP.NET with System.Web.Script.Serialization.JavaScriptSerializer().Serialize() or JSONSharp.JSONReflector().
public ActionResult GetActivity(DateTime date)
{
    // Get a day worth of data.
    var all = activityLogRepository.FindAllActivityLogs(date);
    // Group the data but hour.  I am using an enumerable and GroupBy but this isn't accurate as it does not include hours with no records.
    // The correct way to do it would be a for(x = 1; x <= 24; x++) data.push({ x = x, y = all.Where(item => item.timestamp.hour = x).count()};
    var data = all.GroupBy(item => item.timestamp.Hour, (key, list) => new { x = key, y = list.Count() } );
    // Return the data under aaData not as a top level item so we can pass errors back.
    return Json(new { aaData = data });
}

highcharts.js
// Load data from the AjaxSource specified in the chard on load.  To do this add a callback to the chart init event.
window.Highcharts.Chart.prototype.callbacks.push(function(chart) {
    chart.reloadAjax(false);
});

// Reload all the data in the chart from the ajaxSource of the chart or individial series. Multiple ajaxSource items may be specified so 
//  we dont have the option of overriding the ajaxSource.  Instead use a #param variable in the ajaxSource and set the parameter via a input field. 
// If appendData is set then charts will not me empties but rather new data will be merged in.
// If shift is set then set then individual points will be cycled out as new points are added to a series.  Note that this isn't chart wide so a 
//  series that isn't updated won't change at all, you would have to pass zero's in or manualy remove the old data to make the entire chart rotate as one.
window.Highcharts.Chart.prototype.reloadAjax = function(appendData, shift) {
    var chart = this;

    // Overwrite existing data by default.
    if (typeof appendData == 'undefined')
        appendData = false;

    chart.showLoading();

    // Either append data to waht is already in the chart or reset the chart.
    if (appendData) {
        // Look for any series with an ajaxSource.
        $($.grep(chart.series, function(chartData) { return chartData.options.ajaxSource != null; })).each(function() {
            var chartSeries = this;

            // Read the chart data from a JSON query.
            $.getJSON(chart.updateProperties(chartSeries.options.ajaxSource), null, function(json) {
                for (var point in json.aaData) {
                    // Add each point to the series.
                    chartSeries.addPoint(json.aaData[point], false, shift);
                }

                // Update the chart.  There may be more pending JSON requests but can't easily determine that so we show this early.
                chart.redraw();
                chart.hideLoading();
            });
        });

        if (chart.options.chart.ajaxSource) {
            // Read the chart data from a JSON query.
            $.getJSON(chart.updateProperties(chart.options.chart.ajaxSource), null, function(json) {
                // For each series in the results.
                for (var jsonSeries in json.aaData) {
                    // Look for an existing series in the chart with the same name.
                    var chartSeries = $.grep(chart.series, function(chartData) { return chartData.name == json.aaData[jsonSeries].name })[0];

                    if (chartSeries == null) {
                        // If there isn't one then add a new series.
                        chart.addSeries(json.aaData[jsonSeries], false);
                    } else {
                        // Otherwise add a new series.
                        for (var point in json.aaData[jsonSeries].data) {
                            // Add each point to the series.
                            chartSeries.addPoint(json.aaData[jsonSeries].data[point], false, shift);
                        }
                    }
                }

                // Update the chart.
                chart.redraw();
                chart.hideLoading();
            });
        }
    } else {
        // Look for any series with an ajaxSource.
        $($.grep(chart.series, function(chartData) { return chartData.options.ajaxSource != null; })).each(function() {
            var chartSeries = this;

            // Read the chart data from a JSON query.
            $.getJSON(chart.updateProperties(chartSeries.options.ajaxSource), null, function(json) {
                // Put the new data into the series.
                chartSeries.setData(json.aaData);

                // Update the chart.  There may be more pending JSON requests but can't easily determine that so we show this early.
                chart.hideLoading();
            });
        });

        if (chart.options.chart.ajaxSource) {
            // Read the chart data from a JSON query.
            $.getJSON(chart.updateProperties(chart.options.chart.ajaxSource), null, function(json) {
                for (var chartSeries = 0; chartSeries < chart.series.length; ) {
                    // Remove the old series unless they have their own ajaxSource.
                    if (chart.series[chartSeries].options.ajaxSource == null)
                        chart.series[chartSeries].remove(false);
                    else
                        chartSeries++;
                }

                for (var jsonSeries in json.aaData) {
                    // Put the new data into the series.
                    chart.addSeries(json.aaData[jsonSeries], false);
                }

                // Update the chart.
                chart.redraw();
                chart.hideLoading();
            });
        }
    }
};

// Update an impending ajax calls request url to reflect local data.  For each #word formated
//  block in the url we will look for a tag with the same id, minus the #, and update our url
//  with the found value.  If no object with a matching id is found the original value is left.
// Eg: Take the url /Load/#uid/#date.  This will cause the #uid to get replaced with $("#uid").val()
//  and the #date to get replaced with $("#date").val().
window.Highcharts.Chart.prototype.updateProperties = function(url) {
    var parts = url.replace(/%23/g, "#").split(/(#\w+)/);
    var result = "";
    for (var count = 0; count < parts.length; count++) {
        if (parts[count].match(/^#/)) {
            if ($(parts[count]).is("input[type=checkbox]"))
                result += $(parts[count]).is(':checked')
            else if ($(parts[count]).is("span"))
                result += $(parts[count]).text()
            else
                result += $(parts[count]).val()
        } else
            result += parts[count];
    }

    return result;
};

3 comments:

boohoi said...

Please publish working code sample. How can I send data from backend code (from .CS )


Thanks and Advance

iamcootis said...

Hi Jeremy,

I am using this method, but the reloadAjax() code isn't redrawing the chart.

The chart is drawn correctly on load.

Is there something I could be doing wrong?

Jeremy Pyne said...

Sorry this project got put onto the back burner so I haven't been able to finish it. If I recall the refresh method isn't entirely complete when it comes to processing series. Adding a series in the JSON call wont show up for example. Some day i'll get back ti finishing up the tool.