Wednesday, August 4, 2010

Custom ColumnChart Labels using a Custom Item Renderer

The Charting components are usually very flexible and fit most of your needs, but sometimes they fail in the simplest of things.

For example, the other day I needed a basic ColumnChart for a business application I was working on. The request was that instead of mouse-over Data-Tips, for each column a small label would appear with its value. Sounds simple enough, right?

At first for the ColumnSeries I set labelPosition to "inside",


and I got this:

 OK, so the last column is too short to contain the label, but it would be nice if the ColumnSeries nudged it up a bit. Alas, no such luck.

Oh well, I thought, it wouldn't make much of a difference if the labels were to be outside, so I changed labelPosition again and this time I got:

Two problems: First, I wanted the labels outside, so why is the "0.9" inside the bar?
And second, why are my labels suddenly aligned to the left? I want them centered! And I know that it's possible.

You'd think adding the property labelAlign to "center" would fix that. Wrong again! When labelPosition is set to outside the only alignment possible is left. Why? Makes no sense to me...

Anyway, to make a long story short, after some research I came to the conclusion that the easiest way to fix these issues would be to simply write my own ColumnSeries item renderer, adding the labels to it manually.

Don't worry, no more "buts"; Writing an Item Renderer from scratch in this case is super easy! All you need to do is extend UIComponent making sure that you implement the IDataRenderer interface.

The result we want to achieve is this:

 Features:
  • If a bar is too short to fully contain its label (like Europe in the screenshot), the label should appear above it.
  • The labels should be horizontally and vertically centered.
  • If the label is outside a bar, its color should change.
So, how do we do it?
First, we tell the ColumnSeries that we we want to use our own class (Let's call it  ColumnBarRenderer) as a renderer. We'd better also remove the labelPosition property because we're going to put our own label.


Now let's create the class. Like I said, it should extend UIComponent and implement IDataRenderer.

Implementing IDataRenderer is actually very simple - all we need to do is include a getter and a setter for the "data" object. In this case, the data object that will be passed to us will be of type  ColumnSeriesItem (base class: ChartItem). We need this data in our renderer because it contains all we need to know about the column bar we are drawing.

private var _chartItem:ChartItem;
  
  public function set data(value:Object):void
     {
         if (_chartItem == value) return;
           // setData also is executed if there is a Legend Data 
           // defined for the chart. We validate that only chartItems are 
           // assigned to the chartItem class. 
         if (value is LegendData) 
          return;
         _chartItem = ChartItem(value);         
     } 
  
     public function get data():Object
     {
         return _chartItem;
     }
The second step is to create our custom label. In a UIComponent the creation of child components is usually done in the createChildren() function. So let's override it and create our label there, formatting it as we like:

private var label:Label;

  override protected function createChildren():void
  { 
   super.createChildren();
   if (label == null)
         {
          label = new Label();
          label.truncateToFit = true; 
          label.setStyle("fontSize", 12);       
          label.setStyle("textAlign", "center");
          addChild(label);
         }         
  }

Now, you're probably thinking : "I somehow need to know the dimensions of the column I'm drawing, don't I?" Well, No. Because the ColumnChart already sizes every renderer that it creates, when we update the display list for our renderer, the size of our UIComponent is already the calculated size of the column (according to its current value).

Here's a simplified version of our renderer's updateDisplayList() function (the code that handles the label is omitted):

override protected function updateDisplayList(unscaledWidth:Number,unscaledHeight:Number):void
  {
   super.updateDisplayList(unscaledWidth, unscaledHeight);
   
   var rc:Rectangle = new Rectangle(0, 0, width, height);        
   var g:Graphics = graphics;
   g.clear();        
   g.moveTo(rc.left, rc.top);
   
   //
   // handle label position here
   //
   
   if (_chartItem == null) // _chartItem has no data
    return;
   
   // Draw the column
   g.beginFill(0xff0000, 0.9);
   g.lineTo(rc.right,rc.top);
   g.lineTo(rc.right,rc.bottom);
   g.lineTo(rc.left,rc.bottom);
   g.lineTo(rc.left,rc.top);
   g.endFill();    
  }

Note that we simply draw a box from (0,0) to (width,height) of the component. We don't have to deal with the calculations of how high the column should be depending on its value, or how wide, etc. All of this is already done for us.  Pretty neat, huh?

So far so good, but now comes the tricky part, i.e. positioning the label vertically-(and horizontally)-centered inside each column, except when there's no room for it (because the column value is too low), in which case we need to position it slightly above the bar. And let's not forget to set the text of the label, containing its numeric value.

Like I said before, the only way for our renderer to get any information about the chart and its data is through the data property which we stored in the getter (in the _chartItem which is a ColumnSeriesItem).

ColumnSeriesItem contains all the information we need about the box we're currently drawing (including the data row itself), but also the ColumnSeries (through the .element property). Specifically, we get from that the yField of the series, so we can extract the value from the data item and set it as the label's text.

OK, let's cut to the chase. Here's how we set the label:

var cs:ColumnSeries = _chartItem.element as ColumnSeries;
var csi:ColumnSeriesItem = _chartItem as ColumnSeriesItem;
   
// set the label           
label.text = csi.item[cs.yField].toString();          
label.width = label.maxWidth = unscaledWidth;         
label.height = label.textHeight;
var labelHeight:int = label.textHeight + 2;
In lines 1 and 2 we extract the ColumnSeries and the ColumnSeriesItem from the data.
In line 5, we use the series' yField property to get the numeric value of the current column from the item itself.
In line 6 the width of the label is set to match the total width of our column so that the label will be centered (remember that in createChildren we set the textAlign of the label to 'center'). Line 7 sets the height to match the label's actual height (depending on the its set font size). Finally we store that height.

Now let's see how we position the label:

// label's default y is 0. if the bar is too short we need to move it up a bit
         var barYpos:Number = csi.y;
         var minYpos:Number = csi.min;
  var barHeight:Number = minYpos - barYpos;
  var labelColor:uint = 0xFFFFFF; // white
        
         if (barHeight < labelHeight) // if no room for label
         {
    // nudge label up the amount of pixels missing
          label.y = -1 * (labelHeight - barHeight);
          labelColor = 0x222222; // label will appear on white background, so make it dark          
         }
         else
          { 
          // center the label vertically in the bar
                 label.y = barHeight / 2 - labelHeight / 2;
          }
   
  label.setStyle("color", labelColor);

The key to understand this code is to know what the two properties ColumnSeriesItem.y and ColumnSeriesItem.min mean.
If our chart's top y position is 0, then csi.y is where our column box begins (or ends), and csi.min is where it ends (or begins, depends how you look at it).
What I mean is :


Once you know this, checking if our label fits in the column box is a piece of cake, as well as nudging it up a bit if needed, and centering it vertically.

The full source can be found here. (RAR file)
For a ZIP file click here.

6 comments:

  1. Thanks for the post. Would you be able to post a zip file? Don't have access to a rar tool in my environment.

    ReplyDelete
  2. Thanks for this tutorial! I've been searching for a way to anchor the data labels on my bar chart; and this has helped demystify programmatic itemRenderers a little for me too :)

    ReplyDelete
  3. thanks for this tutorial... it was really help my problems,, but i got another problem i.e., iam getting labels with drop shadow can u clear this issue

    ReplyDelete
  4. krishna s: You probably have drop shadow filter set on your chart control. Clear filters by setting ColumnChart's seriesFilters property to []..

    ReplyDelete
  5. Superb!! Thank you very much.

    ReplyDelete

DermaRoller