Ext JS 4 Stock Charts

I've been working with Ext JS 4's pure Javascript charting package for a while now and due to the lack of decent real-world examples (i.e. those that don't just use almost all default settings) and a few undue omissions in the documentation, it's not always been plain sailing. In an attempt to rectify this situation, in this post I'll show some of the tricks that can be used to customise an Ext JS chart. The result is a daily stock chart with tracking behavior and customised styling. All the source code for this example can be downloaded here: extjs4-chart-blog-mr.zip.

The stock chart I'll run though is given below and is similar to those a lot of financial companies use on their websites to show stock or index data (the ScottLogic homepage has one). It even uses the "industry standard blue" colour for the series!

This chart shows the percentage difference between a previous closing price and today's prices for a fake stock. The data does not update in real time but is randomly generated - try refreshing the page to see how it looks with different values. To make it easier to see what's going on I'll descibe how to build the chart up step by step; starting with basic version that's not very customised.

Basic ChartThe following code defines a stripped down version of the above chart.

Ext.onReady(function(){
    //format of the data given to the chart..
    var dataDateFormat = "H:i";

    //Converts times in the "dataDateFormat" to values..
    function convertTimeToValue(timeStr){
        var time = Ext.Date.parse(timeStr, dataDateFormat);
        time.setFullYear(1970,0,1); //prevents problems with large numbers..
        return time.getTime();
    }

    //Create charts and add them to the page..
    Ext.each(Ext.query('div.chartHolder'), function(rootDiv){
	//Get some fake data and convert the dates to values..
	var mockRawData = generateMockStockData();
	Ext.Array.each(mockRawData.data, function(point, i){
		point.time = convertTimeToValue(point.time);
	});

	//define the Ext chart...
	var chart = Ext.create('Ext.chart.Chart', {
	    store: Ext.create('Ext.data.JsonStore', mockRawData),
	    shadow: false,
	    legend: false, //we'll roll our own..
	    axes: [{
	        type: 'Numeric',
	        fields: ['value'],
	        position: 'left',
	        minorTickSteps: 0,
	        grid: true
	    }, {
		type: 'Numeric',
		position: 'bottom',
		fields: ['time'],
		minorTickSteps: 0,
		grid: true
            }],
	    theme: "DailyStockChart",
	    series: [{
	        type: 'line',
	        axis: ['left', 'bottom'],
	        xField: 'time',
	        yField: 'value',
	        showMarkers: false
	    }]
        });

        //Add the chart to the page..
        Ext.create('Ext.container.Container', {
	    layout: 'fit',
	    width: 450,
	    height: 300,
	    renderTo: rootDiv,
	    items: chart
        });
    });
});

The chart is backed by a JSONStore which expects an JSON object in the form:

{
    "fields": ["time", "value"],
    "data": [
        { "time": "08:02", "value": -0.45 },
        { "time": "08:03", "value": -0.451 },
        ...
    ],
    "stockName": "Mock Mega Corp."
}

This is representative of something that could be generated server side from a data feed, however, in this case it's just created by the generateMockData function which is defined in the mockStockData.js file. The fields and data properties are mandatory, the stockName is a custom value which isn't used yet - in general there's nothing stopping you adding any properties to the object that is passed to the data store.

The above code is fairly standard, except perhaps for the use of a "Numeric" axis for the time values. There is a "Date" axis included in the chart package, but oddly, it's based on a "Category" axis, which means it's not able to handle time series data, particularly when the time difference between points is variable. Since the times are being rendered on a numeric axis, we need to convert them to numbers before adding them to the chart; this is done on lines 16-18. The numeric axis doesn't handle large numbers correctly so the convertTimeToValue function which deals with the conversion assumes that the times it gets are on the date to the epoch (Jan 1st 1970), so that the number of milliseconds is minimised.

The resulting chart looks like this:

Axes FormattingYou'll probably agree that the labels on the axes in the above image are less than ideal - firstly they need formating, but also they need to be positioned at more sensible values.

These problems are solveable, however, without writing a new axis class from scratch, the best you can do is to be able to determine the values for the start and end labels and configure the number of steps to use inbetween. This might not seem like much of a draw back, but consider the time axis on this chart; the time range is always the same - 8.00am till 4.30pm (the time that the fake exchange is open), and we'd like to put labels at sensible points in time and not too often. However, we can't set the gap between values to be say two hourly because the time difference is 8.5 hours which isn't divisible by 2. Therefore, we are forced to set the time gap to half an hour then add a bit of a kludge so as to only render labels at sensible gaps. The result looks ok, but it would be nice to be able to do it with fewer vertical lines!

The new axis configuration and accompanying functions when added to the base code look like this:

Ext.onReady(function(){
    ...

    //Returns a "stepsCalc" object which describes the minimum, maximum
    //and steps to use for the value axis based on the data points given..
    function getValueAxisStepsCalc(points){
        //get the real max and min values from the data..
        var minValue = 0;
        var maxValue = 0;

        //get the correct max/min values and converts dates to numbers..
        Ext.each(points, function(point, i){
	    var value = point.value;
	    if(value < minValue)
	        minValue = value;
	    if(value > maxValue)
	        maxValue = value;
        });

        var range = maxValue - minValue;
        var minClearance = Ext.max([range * 0.1, 0.01]);

        //get a sensible step between major ticks on axis - bit of a black art!
        var step = 0.01;
        var i = 1;
        while(range > step * 10)
            step *= ++i % 3 ? 2 : 2.5;

        //ensure 10% clearance then round to step value..
        var axisMin = Math.floor((minValue - minClearance)/step) * step;
        var axisMax = Math.ceil((maxValue + minClearance)/step) * step;
        var steps = Math.round((axisMax - axisMin)/step);

        return {
            from: axisMin,
            to: axisMax,
            step: step,
            steps: steps
        };
    }

    function getTimeAxisStepsCalc(){
        var stepsCalc = {
	    from: convertTimeToValue("08:00"),
	    to: convertTimeToValue("16:30"),
	    step: 1800000 // 1800000 = 30*60*1000
        };
        stepsCalc.steps = (stepsCalc.to - stepsCalc.from)/stepsCalc.step;
        return stepsCalc;
    }

    var timeFormatter = function(timeVal){
        return Ext.Date.format(new Date(timeVal), "g:iA");
    };
    var valueFormatter = function(value){
        return value.toFixed(2)*1 + "%";
    };

    Ext.each(Ext.query('div.chartHolder'), function(rootDiv){
        ...
        var valueAxisSteps = getValueAxisStepsCalc(mockRawData.data);
        var timeAxisSteps = getTimeAxisStepsCalc();

	//define the Ext chart...
	var chart = Ext.create('Ext.chart.Chart', {
            ...
	    axes: [{
	    type: 'Numeric',
	        minimum: valueAxisSteps.from, //essential for correctness!
		maximum: valueAxisSteps.to, //essential for correctness!
                position: 'left',
		minorTickSteps: 0,
		grid: true,
		applyData: function(){
		    return valueAxisSteps;
		},
		label: {
		    //prevent rounding errors and and we want to add '%' to the end..
		    renderer: valueFormatter
		}
	    }, {
	        type: 'Numeric',
	        position: 'bottom',
	        minimum: timeAxisSteps.from, //essential for correctness!
	        maximum: timeAxisSteps.to, //essential for correctness!
	        minorTickSteps: 0,
	        grid: true,
	        applyData: timeAxisSteps
	        label: {
	            //need to convert the numbers back to formatted dates..
	    	    renderer: function(val){
                                  // 7200000 = 2 *60 * 60 * 1000
		        return val % 7200000 ?
                                    "<span style='visibility: hidden;'></span>" :
                                    timeFormatter(val);
		    }
	    }
        }, { //dummy axis - forces the top label on left axis to be in line..
            position: 'top',
	    type: 'Numeric'
        }],
        ...

In the above code snippet I've tried to indicate where the bits should be added to the above basic chart code by including the lines that define the scope that code goes in, and "..."'s to indicate where code has been omitted.

There are two basic things that can be done to sort the axis labels - override its applyData function and set the label renderer. The applyData function returns what is referred to in the Sencha code as a stepsCalc object. This sets out the smallest and largest axis values and the step between the major ticks (where the labels appear). The use of this function isn't documented, so you can't blame Sencha if they remove it from a future release (however nor is the axisStyle property on the Axis class which is fairly essential!); what is documented instead is the use of the minimum, maximum and steps configuration properties to control the axis tick positions. However, for some reason, setting these is more like a suggestion - it isn't guaranteed to work. You should be aware that if you do override the applyData function for an axis, you must also set the maximum and minimum axis properties to the to and from values of the stepsCalc object which this function generates - failure to do this means that the chart will render fine, but the values it shows won't appear in the right place!

By providing a custom label renderer function you can not only format the values but also, by returning the empty string, control which major ticks have labels on them. This is what is done on lines 91-96 with the time axis; this function only returns a visible value in the case that it's a multiple of 2 hours. The reason that the label renderer doesn't just return the empty string or a space character in the case that we want to hide the label is that, due to a bug, this prevents the entire chart rendering in Opera. If you still can't get the effect you want using just the label renderer and applyData properties your only choice is to customise the drawAxis function which essentially controls exactly what the axis looks like - however doing this generally requires a significant amount of code, and you'll need to pick through Sencha's source to figure out what you need to do.

The most interesting bit of code in the above block is in the getValueAxisStepsCalc function which provides the stepsCalc for the value axis. Of particular note are lines 24-27 which calculate the steps between the labels based on the range. It takes a while to get what's going on in line 27, but it's fairly useful. It's based on the fact that I (and I guess most people) are generally most happy dealing with things of units of 1,2 and 5 - anything else is complicated.

Another oddity in the above axis configuration is the inclusion of the top axis - this doesn't actually do anything but its inclusion forces the top most label on the left hand axis to be positioned in the position I would expect it to be - otherwise it appears slightly lower.

Series StylingA series is styled in much the same way as any other sprite in Ext - by supplying a style configuration parameter which takes values for the fill (a colour), stroke (the line colour), stroke-width and an opacity value (between 0 and 1). This style param is effectively just copied onto the SVG element that represents it. One consequence of this is that you can also set other SVG style properties in there too - including the stroke-dasharray which allows you make the series line dashed. The only downside is that it won't work in VML which is what it the chart is rendered using in IE6 - IE8 (in fact for some reason it doesn't work in IE9 either, even when using SVG).

Although it might appear that there is only a single series on the chart in fact there are three. One for the line of the stock series, one for the fill and one for the red dashed line at zero. The reason there are two separate series for the stock is simply a hack to get round a couple of rendering bugs - firstly that the opacity value is used for both the line and the fill - therefore there's no way to get a solid line and a see-through fill in a single series and secondly (although it's not important in this case) the fill overlaps part of the series line (try setting the stroke-width high and changing the fill colour to see the effect!). To get the red "zero-line" series in, I add an extra previousClose property to the JSON object backing the chart, and add points at 8am and 4.30pm with a value of zero for this property prior to creating the chart object. The code you need to add to do this is:

...
mockRawData.fields.push("previousClose");
mockRawData.data.unshift({
	previousClose: 0,
	time: convertTimeToValue("08:00")
});
mockRawData.data.push({
	previousClose: 0,
	time: convertTimeToValue("16:30")
});

//define the Ext chart...
var chart = ...

The series configuration object (before adding the tracking behaviour), looks like this:

series: [{
    //the main stock data line series..
    type: 'line',
    axis: ['left', 'bottom'], //required for v.4.0.7..
    xField: 'time',
    yField: 'value',
    showMarkers: false,
    style: {
        stroke: '#5555FF',
        'stroke-width': 1
    }
}, {
    //fake series which just shows the fill..
    type: 'line',
    axis: ['left', 'bottom'], //required for v.4.0.7..
    fill: true,
    xField: 'time',
    yField: 'value',
    showMarkers: false,
    style: {
        'stroke-width': 0,
        fill:  '#3333FF',
        opacity: 0.2
    }
}, {
    //series which highlighs the 0% line, inicating the previous close value..
    type: 'line',
    axis: ['left', 'bottom'], //required for v.4.0.7..
    xField: 'time',
    yField: 'previousClose',
    showMarkers: false,
    style: {
    	stroke: '#FF3333',
    	'stroke-dasharray': [9,5], //ignored in browsers that don't support SVG (and IE9)..
        'stroke-width': 1,
        opacity: 1
    }
}]

LegendAlthough Ext charts can be configured with a legend parameter, it's a bit limited in terms of what you can achieve with it. You can set the general position and style of it but not exactly what goes on it and perhaps most annoyingly there doesn't seem to be any way to easily turn off the click handler on it which hides the series being highlighted - if someone figures out how please let me know!

To get around this, I create the legend myself using good old HTML and CSS. The DOM elements for the legend are created using Ext's templating library which is pretty powerful and worth checking out.

The code that needs to be added for the legend is this:

...
var miniLegendTemplate = new Ext.XTemplate(
    '<div class="miniLegend">' +
    '  <div class="time">{defaultTime}</div>' +
    '  <div class="marker" style="background-color: #5555FF;"></div>' +
    '  <div style="color: #3333FF;">' +
    '    <span class="stockName">{stockName}</span>' +
    '    <span class="value">{defaultValue}</span>' +
    '  </div>' +
    '</div>'
);

//Create charts and add them to the page..
Ext.each(Ext.query('div.chartHolder'), function(rootDiv){
    ...
    var lastPoint = mockRawData.data[mockRawData.data.length-1];
    var latestValueFormatted = valueFormatter(lastPoint.value);
    var latestTimeFormatted = timeFormatter(lastPoint.time);

    //define the Ext chart...
    var chart = Ext.create('Ext.chart.Chart', {
        ...,
	listeners: {
            afterrender: function(){
	        var el = miniLegendTemplate.append(this.el, {
    	            stockName: mockRawData.stockName,
	            defaultValue: latestValueFormatted,
	            defaultTime: latestTimeFormatted
	        });
            }
        },
        cls: 'dailyStockChart',
        ...

At this point the chart is the same as the live version, except that nothing happens when the mouse goes over it. The point described in the legend is the last (current) data point. The above code is fairly standard Ext JS code, the addition of the 'cls' property is so that custom css can be used to style the legend as required without interfering elsewhere. There's nothing particularly special about the css used; this is specified in the dailyStockChart.css file.

Tracking BehaviourThe mouse tracking behaviour on the chart is produced by adding Ext's standard series highlighting behavior to the stock series (which is done simply by setting the highlight property to true), and then overriding some of the functions it uses.

The three things that need to be overridden in the series configuration for it to work are the getItemForPoint function which fires whenever the mouse moves over the drawing surface of the chart and returns the item to highlight (or null if nothing should be highlighted), highlightItem which takes the newly highlighted item and alters its style to highlight it and unhighlightItem which should perform the inverse of this action and actually takes no parameters. The reason unhighlightItem takes no parameters is because it must unhighlight all highlighted items - which is (unfortunately) potentially more than one - this is because if you move the mouse off the drawing surface quickly then back on again it's possible for the highlightItem function to fire twice in a row.

The trick to getting style right is to set showMarkers on the stock series to true, set the style of the markers so that they are invisible; then, when highlighted, the legend is updated and the marker style changed to make it visible.

The code changes needed to make it work are:

...
Ext.each(Ext.query('div.chartHolder'), function(rootDiv){
    ...
    var legendValueResetTimeout; //timeout used to set the legend back to default..
    var legendTimeEl; //gets set to the time field of the legend when it exists..
    var legendValueEl; //gets set to the value field of the legend when it exists..
    ...
    //define the Ext chart...
    var chart = ...
        listeners: {
        afterrender: function(){
		var el = ...
		legendTimeEl = new Ext.Element(Ext.query('.time', el)[0]);
		legendValueEl = new Ext.Element(Ext.query('.value', el)[0]);
	    }
	},
	...,
	series: [{
            //the main stock data line series..
	    ...,
	    showMarkers: false,
	    markerConfig: {
	        radius: 0,
	        fill: '#5555FF',
	        'stroke-width': 0
	    },
	    highlight: true,
	    //returns the point which has the nearest x value only.
	    getItemForPoint: function(x, y) {
	        //adapted from Sencha dev code..
	        var items = this.items;
	        if (!items || !items.length || !Ext.draw.Draw.withinBox(x, y, this.bbox))
                    return null;
		var nearestItem = null;
		var smallestDiff = Number.MAX_VALUE;

		//do binary search to find item with the nearest x point..
		var lowIndex = 0, highIndex = items.length - 1, currentIndex;
		while (lowIndex <= highIndex) {
		    currentIndex = Math.floor((lowIndex + highIndex) / 2);
		    var item = items[currentIndex];

		    var diff = item.point[0] - x;
		    var absDiff = Math.abs(diff);
		    if(absDiff < smallestDiff){
		        nearestItem = item;
		        smallestDiff = absDiff;
		    }
		    //update bounds of search..
		    if(diff < 0) lowIndex = currentIndex+1;
		    else if(diff > 0) highIndex = currentIndex-1;
		    else break; //equal case..
		}
		return nearestItem;
            },
	    highlightItem: function(item) {
	        if(!item)
	            return;
	        item.sprite.setAttributes({
	            radius: 4
	        }, true);
	        item.sprite._highlighted = true;
	        //update the legend..
                clearTimeout(legendValueResetTimeout);
                legendTimeEl.update(timeFormatter(item.storeItem.data.time));
                legendValueEl.update(valueFormatter(item.storeItem.data.value));
	    },
            unHighlightItem: function(){
	        //adapted from Sencha dev code..
		var items = this.items;
		if(!items)
		    return;
		for(var i = 0, len = items.length; i < len; i++){
		    var sprite = items[i].sprite;
		    if(sprite && sprite._highlighted){
		        sprite.setAttributes({
			    radius: 0
			}, true);
			delete sprite._highlighted;
		    }
		}
		//reset the legend (use timeout to prevent excess dom manipulation)..
                legendValueResetTimeout = setTimeout(function(){
	    	    legendTimeEl.update(latestTimeFormatted);
		    legendValueEl.update(latestValueFormatted);
	        }, 100);
	    }
	}, ...

The only excessive bit here is the use of a timeout when resetting the legend after the item is unhighlighted; this is so as to prevent it updating the DOM by setting it to the default value in the case that highlightItem is called straight afterward.

If you're starting developing with the Ext JS 4's chart library, I hope this has given you at least a few tips to put into your own charts; let me know what you think or if there's anything up with the code. I should probably also point out that I've not had the chance to test this on IE6-IE8, so it's pot luck if it works in them too!

MORE BY MARK

blog comments powered by Disqus