Friday, October 23, 2009

LineChart with CheckBox Legend (Filter Series)

The following example shows a LineChart with a custom Legend that has CheckBoxes next to each LegendItem which allows you to filter the Chart to only show certain series (in this case lines).

I've created a custom class called CheckBoxLegend which extends Legend. It sets the legendItemClass to be the flex.utils.ui.charts.CheckBoxLegendItem class which extends the default LegendItem to add the CheckBox on the left side of the legend item.

Clicking on the legend item toggles the CheckBox and updates the Chart to show or hide the corresponding series. The series is hidden by setting the alpha value to 0.

Here is a snippet of how you use it in MXML:
<mx:Application xmlns:mx="http://www.adobe.com/2006/mxml"
    xmlns:charts="flex.utils.ui.charts.*">

<mx:LineChart id="linechart" ... />
<charts:CheckBoxLegend dataProvider="{linechart}"
    color="#000000" direction="horizontal"/>

</mx:Application>

Here is the example, right click to view source:


Note that the minimum and maximum values of the vertical axis don't get updated when you uncheck one or more series. The Axis calculates these values but doesn't take into account the visibility of the Series. So I've added a new Update Vertical Axis Min/Max CheckBox that will go through all the y axis number values and calculate the minimum and maximum values for the visible series. I haven't tested this out on complicated datasets or different chart types, but hopefully it will be a good starting point.

Originally I played around with actually removing the series from the chart (instead of hiding it), but that caused more problems because when you remove a series the chart will automatically re-color any of the remaining series (unless you specified the stroke/color/fill styles) and the legend gets re-populated without the unchecked series. So it was easiest to just hide the series.

55 comments:

Theo said...

Hi Chris,

thanks for this information, this is really helpful.

Would you also post the source code for "flex.utils.ui.events.CheckBoxLegendItemChangedEvent" as this is referenced in your example.

Cheers,
Theo

Chris Callendar said...

Hi there,

The source code is all there, if you right click and choose "View Source" then you'll be able to browse all the files. Expand the tree on the left to find the CheckBoxLegendItemChangedEvent.as file.

Here is the direct link to the class too:
http://keg.cs.uvic.ca/flexdevtips/checkboxlegend/srcview/source/flex/utils/ui/events/CheckBoxLegendItemChangedEvent.as.html

dk said...

If i wanted to actually remove the series is that possible, so the checkbox and name is removed from the legend as well.

From looking at the code it looks like if i add the below line and remove the series.alpha = (legendItem.selected ? 1 : 0); it will remove the line from the graph, but cant figure out how to remove from the legend.

series.alpha = (legendItem.selected ? 1 : 0);

thanks

Chris Callendar said...

Hi there,

Instead of the line:
series.alpha = (legendItem.selected ? 1 : 0);

You could do something like this:
// Get the Chart which is the legend's dataProvider
var chart:ChartBase = (dataProvider as ChartBase);
// Get the Array of Series objects from the Chart
var seriesArray:Array = chart.series;
// Remove the current Series
var index:int = seriesArray.indexOf(series);
seriesArray.splice(index, 1);
// Set the modified series back on the Chart
chart.series = seriesArray;


Obviously doing it like this you wouldn't have any way to add the series back in since the CheckBox is removed also.

Cheers,
Chris

mario said...

Hello Chris,
First of all thanks for this nice code. It is very helpful.

I just want to add a separate checkbox so that when I click on it, it uncheck or check all the legend checkboxes.

any idea on how the function on that new checkbox could look like?

Chris Callendar said...

Hi Mario,

I've updated the example above to include a CheckBox that selects all or none of the legend items.

To do that I added two new functions to the CheckBoxLegend class: selectAll(), and selectNone().

Chris

Mario said...

Hi Chris,
Thanks for the checkbox.

But it seems that there is a small bug : when you deselect all/or one of the lineseries, and mouse over the chart area, the datatip of the deselected series are still showing!

Regards,
Marius

Chris Callendar said...

Hi Marius,

Thanks for pointing that out. How embarrassing!

I obviously wasn't thinking very clearly when I originally posted this entry. Instead of setting series.alpha = 0 to hide the line it now sets series.visible = false instead. And this prevents the tooltips from showing up too,

Thanks again for the comment.
Chris

Anonymous said...

Hi Chris,

I change the CheckBoxLegend's direction to vertical. And added a verticalScrollPolicy to "on". When the height is reduced, the verticalScrollPolicy does not apply. The legend items show in two columns, one beside another instead of showing a scroll bar and one column.
Any idea on the cause of this?

Thanks in advance,
Marius

Chris Callendar said...

Hi Marius,

The base Legend class doesn't support verticalScrollPolicy (even though it appears to). Instead I'd suggest wrapping the CheckBoxLegend inside a Canvas, VBox, or HBox and setting your scroll policies on it.

Chris

Marius said...

Hi Chris,

Thanks for the tip.It works now!

Have a nice day,
Marius

Jon said...

Hi Chris,

Thanks for this component - it rocks!

One quick question: is it possible to force the vertical chart axis to resize when a series is hidden or shown? For example, if one series has very large values, then it would be great for the axis to 'shrink' when that series is hidden.

Is this possible?

Thanks,
Jono

Chris Callendar said...

Hi Jono,

I've updated the example above to include the Update Vertical Axis Maximum CheckBox. If selected causes then when the legend items change the vertical axis (a LinearAxis) maximum value will be calculated using only the data points from visible series. Since the source code for the charting classes aren't available I don't know of a better way to do this.

Chris

Jon said...

Chris,

Thanks, that works (almost) perfectly. I added in some code to take two additional conditions into account:
1. Calculate the minimum value too (since I sometimes have charts that go below the zero line).
2. Take into account series other than lineSeries (eg columnSeries).

I have a really weird bug though with column series. Have a look at the video that I uploaded to http://www.youtube.com/watch?v=S_3I5WUiDBA

Note that the Net Profit series are both based on the same data - the only difference is that one is a columnSeries and the other is a lineSeries. When the only visible series is a columnSeries and that is hidden and then made visible again, the chart is not updated properly. Debugging the code in this scenario (i.e. no series are shown, and then the column series is made visible) seems to show that there is no items being passed through to the getMinmax function.

private function updateVerticalAxis():void {
var min:Number = 0;
var max:Number = 0;
vaxis.autoAdjust = false;
trace('----start chart----');
for each (var series in profitChart.series) {
if (series.visible) {
var items:Array = series.items;
var seriesMinMax:Array = getMinMax(items);
min = Math.min(min, seriesMinMax[0]);
max = Math.max(max, seriesMinMax[1]);
}
}
//if (max != Number.MIN_VALUE) {
vaxis.maximum = max;
//}
//if (min != Number.MIN_VALUE) {
vaxis.minimum = min;
//}
vaxis.autoAdjust = true;
trace('----end chart----');

}

private function getMinMax(items:Array):Array {
var min:Number = 0;
var max:Number = 0;
var count:int = items.length;
var i:int = 1;
trace('--start series--');
for each (var item:* in items) {
var num:Number = item.yNumber;
min = Math.min(min, num);
max = Math.max(max, num);
trace('item ' + i + ' of ' + count);
trace('num:' + num + ' min:' + min + ' max:' + max);
i++;
}
var seriesMinMax:Array = new Array(min, max);
trace('minMax');
trace(seriesMinMax);
trace('--end series--');
return seriesMinMax;
}

I wonder if I am missing something blindingly obvious here...

Jon said...

OK, potential fix found. If there are no series visible, then the vertical axis is not resized.

private function updateVerticalAxis():void {
var min:Number = 0;
var max:Number = 0;
var count:int;
vaxis.autoAdjust = false;
trace('----start chart----');
for each (var series in profitChart.series) {
if (series.visible) {
count++;
var items:Array = series.items;
var seriesMinMax:Array = getMinMax(items);
min = Math.min(min, seriesMinMax[0]);
max = Math.max(max, seriesMinMax[1]);
}
}

if (count > 0) {
vaxis.maximum = max;
vaxis.minimum = min;
}
vaxis.autoAdjust = true;
trace('----end chart----');

}

This seems to work on initial testing..

This next upgrade is to round the min and max properly (i.e. make a ceiling and floor), so that the chart doesnt touch right to the top and right to the bottom. Eg: if max = 9800, then chart should round up to 10000. Will report back when I get there... :)

Chris Callendar said...

Thanks a lot Jon, that's great. I've updated the example above to take into account the minimum values too just like your code does. And I also added some really simple ceil/floor calculations to round the min/max up to the next interval. It seems to work nicely (at least it matches the original min/max values).
Chris

Jon said...

Awesome. Very clever way of calculating the ceiling and floor.

I still have a problem with some series points 'dissapearing' with columnSeries for some strange reason...but otherwise its cool

Jon said...

Chris,
Sorry to be back, but I've found another bug, which occurs when the dataprovider for the chart is changed - it seems that the axis is not reset properly until a legenditem is ticked/unticked

Check http://www.youtube.com/watch?v=zfNRayc40jA

Any ideas on this one?

Jon said...

OK, think I got this one figured out too.

When swapping dataproviders, then its necessary to use the callLater method before the ceil and floor is calculated. If you dont do this, the the ceiling and floor is based on the old data, and not the new data - so the intervals may be totally wrong. So, we have to let the chart calculate the new intervals first, and then increment them using ceil and floor.

private function updateVerticalAxisMinMax():void {
var max:Number = 0;
var min:Number = 0;
var count:int = 0;
for each (var series:LineSeries in linechart.series) {
if (series.visible) {
// contains LineSeriesItem objects
var items:Array = series.items;
// Get the min and max y values for each item from the yNumber property
var minMax:Array = getMinMax(items, "yNumber");
min = Math.min(min, minMax[0]);
max = Math.max(max, minMax[1]);
count++;
}
}
if (count > 0) {
var vaxis:LinearAxis = (linechart.verticalAxis as LinearAxis);
vaxis.maximum = max;
vaxis.minimum = min;
callLater(setInterval);
}
}

private function setInterval():void {
var vaxis:LinearAxis = (linechart.verticalAxis as LinearAxis);
vaxis.maximum = Math.ceil(vaxis.maximum / vaxis.interval) * vaxis.interval;;
vaxis.minimum = Math.floor(vaxis.minimum / vaxis.interval) * vaxis.interval;;
}

Chris Callendar said...

Hi Jon,
This one took a while, but turned out to be very simple.

The reason why the data points disappear is because the vertical axis min/max values are wrong and so the line renderer doesn't draw the points that aren't visible (which makes the gaps in the line).

I've updated my example above to show an updated version which lets you change the data provider like yours. It also has the fix for the disappearing data points.

The two key changes are:
1. When the "Update Min/Max" CheckBox is un-selected, we have to set the vertical axis minimum/maximum values to NaN, this will cause the axis to properly calculate the min/max values (and we won't get disappearing points).
2. After changing the chart dataProvider, you must call linechart.validateNow() and then you can call the updateVerticalAxisMinMax() function. Otherwise you'll run into that problem where the chart hasn't been updated yet and so our calculated min/max values are wrong.

Chris

Kyle Emden said...

Excellent work - very clean and efficient.

Are there any restrictions on using this for commercial development?

Chris Callendar said...

Hi Kyle,

There are no restrictions, it is open source (Creative Commons License).

Chris

Paul Duer said...

I have to agree, this is an indispensable part of any chart with alot of series!

Does anyone know if it's possible to control which series are shown at startup? Like can you default which checks are on for each series?

Paul Duer said...

I figured it out! I modified the AddItem method to be picky about what got turned on and what didn't. I think disabled the Select All function call after startup!

private function legendItemAdded(event:ChildExistenceChangedEvent):void {
// add our change event listener
var defaults:ArrayCollection = new ArrayCollection(new Array("AECI","SWPP","MHEB","ONT","PJM"));
if (event.relatedObject is CheckBoxLegendItem) {
var item:CheckBoxLegendItem = (event.relatedObject as CheckBoxLegendItem);
item.addEventListener(Event.CHANGE, legendItemChanged);

if (defaults.contains(item.label)){
item.selected=true;
var element:IChartElement = item.element;
if (toggleChartSeries && (element is Series)) {
var series:Series = (element as Series);
series.visible = item.selected;
}
}
else {
item.selected = false;
var element:IChartElement = item.element;
if (toggleChartSeries && (element is Series)) {
var series:Series = (element as Series);
series.visible = false;
}
}
}
}

Chris Callendar said...

Looks good Paul, glad to help :)

Anonymous said...

Hi Chris,

I would like to have the chart working like this :
On creationComplete, only some specific items are checked and shown.
for example, Profit, Expenses are checked. Then the user can check other items as he wish.

Any idea on how to do this ?

Thanks in advance
Marius

Chris Callendar said...

Hi Marius,

If you add a creationComplete event handler, you can do this to show or hide various series:

legend.setSeriesShown(profitSeries, true);
legend.setSeriesShown(expensesSeries, true);
legend.setSeriesShown(amountSeries, false);
legend.setSeriesShown(stockSeries, false);

The only change I made to the example above besides adding the creation complete handler was adding the id property to each Series.

Chris

Marius said...

Hi Chris,
Thanks for your answer.
Unfortunetely it doesn't work in my case as I'm adding lineseries in AS3 ont directly in mxml. (in a "for" loop, mylineserie = new LineSeries(); mylinechart.series[i] = mylineserie; )

Dou know how I can resolve this ?

Improvement: chart cursor(vertical line): http://flex.amcharts.com/examples/guides_as_areas

Any idea how to add this feature to your linechart ?

thanks in advance for your help.

Marius

Chris Callendar said...

Do you have access to the legend in your AS3 code when you create those series? If so the code I gave you before should work.

I just made an update that should do what you need for setting the initial visibility. Now, when you create the LineSeries, simply set mylineserie.visible = false; and the checkbox should be unchecked.

As for the Guides, no I have no idea, but it looks pretty cool.

Anonymous said...

ther's a possibility to install this on FLEX 4?

Chris Callendar said...

It should work with Flex 4, give it a try.

riafan said...

The fuction of updating Vertial Axis Min/Max is not right, the Min is always 0

François Jaffrennou said...

Thank you so much for this post, it saved me hours ;-)

I just have one question about the re-calculation of min and max values : the original calculated values of Flex includes a little tolerance. Without this tolerance, my series have sometimes truncated min and max point values.

Do someone know how to calculate this tolerance ? There might have a calculation rule based on the interval and the values ?

Cheers

François Jaffrennou said...

Here is a screen capture to exploain my previous post (truncated max and mix points)

http://www.hostingpics.net/viewer.php?id=598889ChartSeriesjpg.jpg

Chris Callendar said...

If riafan pointed out the min value is always 0. To fix this change the 2 places where it says:
var max:Number = 0;
var min:Number = 0;
to something like this:
var max:Number = int.MIN_VALUE;
var min:Number = int.MAX_VALUE;

As for the tolerance around the min/max values François, if you look at the code I do add some tolerance - it calculates the min/max values and then adjusts them to the next interval. But obviously if your point falls right on the interval, then there is no padding. I don't know how Flex calculates this since I can't see the source code. But I'd suggest trying some combination of the min vs the max and the interval. You could also look at this line in the CheckBoxLegendText.mxml class where I add in some padding:
max = Math.ceil(max / vAxis.interval) * vAxis.interval;
min = Math.floor(min / vAxis.interval) * vAxis.interval;
You could check if the old max equals the new max, in which case you'd need to add some more padding. Same for min.

Chris

François Jaffrennou said...

My tolerance problem is finally due to the CircleItemRenderer used to render the plots on line series.
The plot size is not included in the min/max values.

I found a (not perfect) solution to this problem, which works !

var plotMargin:Number = ( plotSize / ( graphSize / max )) /2;
(verticalAxis as LinearAxis).maximum = max + plotMargin;
(verticalAxis as LinearAxis).minimum = min - plotMargin;

With :
plotSize = the height of the plot point in pixel (around 9 px)
graphSize = The height of the chart in pixels from min to max

Chris Callendar said...

That's look good François. There's always some "fudge" factors involved with charting!

NEMAC Staff said...

This works great and was a real lifesaver! Thanks for posting this!

Xyrer said...

An excellent development, thanks a lot for sharing such a great piece of code.

Stas Ostapenko said...

Great stuff ! Thanks for sharing this !

John Hardin said...

I am using the Dateaxis to render a live value against time, I can get the min and Max set with minutes as my dataunits, but when the data starts to comeing, the graph doesnt show, When I remove the min and max, it shows. please help see code here
http://pastebin.com/U6E3hzNm

Thanks in advance

Chris Callendar said...

Hi John,

Sorry for not getting back to you sooner.

I'm not sure I understand your problem, or see how it relates to the checkbox legend? Your example only lists a single line series, so a checkbox legend wouldn't be of much use.

I haven't used DateAxis much, so I can't really help you much there.

Chris

8e84dba4-c9b6-11e0-9d7f-000bcdcb5194 said...

This is an awesome gift of code/idea here but I am having one little problem.

I am getting "Import Flex could not be found" on ...
import flex.utils.ui.events.CheckBoxLegendItemChangedEvent;

I am guessing it is in my general environment setup or something.
What am I missing?
Thanks ... JohnH

Chris Callendar said...

Hi John,

It sounds like you're missing the event class.

If you right click on the example app above and choose View Source. Then expand out the src tree item on the left, you'll see the class CheckBoxLegendItemChangedEvent.as under flex.utils.ui.events. That class needs to be in your project, perhaps it was missed?

Chris

8e84dba4-c9b6-11e0-9d7f-000bcdcb5194 said...

Ah, I see.
So adding these will get me there?
This is fantastic, thanks.
JohnH

8e84dba4-c9b6-11e0-9d7f-000bcdcb5194 said...

Sorry ... but I am getting a
"Type was not found or was not a compile time constant: CheckBoxLegendItem" in the CheckBoxLegendItemChangedEvent.as file

Thoughts?
JohnH

Chris Callendar said...

Same thing - make sure you add the flex.utils.ui.charts.CheckBoxLegendItem class. Every class that is included in my example above needs to be in your project, except the main application if you've got your own application.

8e84dba4-c9b6-11e0-9d7f-000bcdcb5194 said...

Thank you for sticking with me on this.
I have that problem straightened out but now I am getting this error in my main app file .... "Type was not found or was not a compile time constant: CheckBoxLegend."

This has got to be one silly syntax problem or something equally simple. But I am stumped.
What baffels me is I was not getting this until I got that last issue resolved.
JohnH

Chris Callendar said...

Make sure you have the flex.utils.ui.charts.CheckBoxLegend class inside your project.
Then make sure your MXML includes the namespace charts:

<s:Application xmlns:charts="flex.utils.ui.charts.*">

<charts:CheckBoxLegend/>

Please look at the CheckBoxLegendTest.mxml file and you'll see how it works.

Flex sometimes only shows one error, and when you fix it then it will be able to continue compiling and find other errors.

Aaron Hardy said...

The crux of this issue looks like a Flex bug. Specifically it looks like the _transforms in AxisBase.describeData() are incorrect/out-of-date. Does anyone know if this has been reported in Adobe's JIRA? I couldn't find it.

Anonymous said...

Hey Chris, thanks again for the help getting this going back in Aug.
Now I am wondering how to effect the font on the legend labels. No problems with effecting the size of the marks but I just can't seem to get the label text size to change.
I have tried "fontSize" within an mx:Style as well as within the charts:CheckBoxLegend at the end of the chart.
My goal is to increase the size of the font.
Thoughts?
JohnH

Chris Callendar said...

Hi John,

My apologies for not replying sooner. If you still need help with the Legend labels, here is what I did.

I believe the styles must be set on the LegendItem (or in our case the CheckBoxLegendItem).
So try adding something like this to a Style block in your application (this will apply to all CheckBoxLegendItems):

Flex 3:

CheckBoxLegendItem {
  color: #009900;
  fontWeight: normal;
}

Flex 4:

@namespace charts "flex.utils.ui.charts.*";

charts|CheckBoxLegendItem {
  color: #009900;
  fontWeight: normal;
}

Hope that works for you.

Tahir alvi said...

very nice example - Thank You

Anonymous said...

Hello, I have a line chart in Flex 3 with two series. It feeds on an XML file. How I can do to make each series have tooltips with different data? That is, the series 1, data1 tooltip, tooltip Series 2 with built data2.
data1 and data2 come in the xml. Thank you!

Anonymous said...

I explain better: The data I have nothing to do with the axes, are any data.