Visualizing Data with Core-Plot
Core Plot is an open source graphing framework for Cocoa developers that makes it really easy to add graphs to your applications for the Mac and iPhone. Over the past half year, i’ve had the chance to use Core Plot in several of the internal applications i have been working on and have been very impressed with it, so i wanted to talk a little bit about how to use the library, in general, and how to make generate charts based on data in a Data Abstract for OS X application, in particular.
The example shown here is from a small iPhone application i wrote to keep track of the status of our Continuous Integration servers. The application shows the success (or errors) of any builds done on the machine, as well as – and this is the part where Core Plot comes in – graphs the number of tests that run and fail, over time:
Core Plot uses a Core Animation as underlying technology, and as such draws itself in a specialized view class CPLayerHostingView. So we start out by crating this view, and adding it as sub-view:
-(id)initWithStyle:(UITableViewCellStyle)style { self =[super initWithStyle:style reuseIdentifier:@"MyChartCell"]; if(self){ CGRect frame = self.contentView.bounds; CPLayerHostingView *chartView =[[CPLayerHostingView alloc] initWithFrame: frame]; [self addSubview:chartView]; //... |
// create an CPXYGraph and host it inside the view CPTheme *theme =[CPTheme themeNamed:kCPPlainWhiteTheme]; graph =(CPXYGraph *)[theme newGraph]; chartView.hostedLayer = graph; graph.paddingLeft =10.0; graph.paddingTop =10.0; graph.paddingRight =10.0; graph.paddingBottom =10.0; |
CPXYPlotSpace *plotSpace =(CPXYPlotSpace *)graph.defaultPlotSpace; plotSpace.xRange =[CPPlotRange plotRangeWithLocation:CPDecimalFromFloat(0) length:CPDecimalFromFloat(100)]; plotSpace.yRange =[CPPlotRange plotRangeWithLocation:CPDecimalFromFloat(0) length:CPDecimalFromFloat(100)]; |
The X/Y graph already provides a set of axis in for of a – you guessed it – CPXYAxisSet class. There are several properties on the axis’ that are worth looking at at tweaking to to suite out needs:
- majorIntervalLength defines the number of units between “big” ticks on the axis. In this case it’s set to show one very 10 units. (if it were not for the exclusionRanges, discussed below, a numeric label would show for each major tick, as well).
- minorTicksPerInterval specified how many small ticks will be shown between each major one. in this case, a value of 2 indicated that small would show for 5, 15, 25, etc. (a value of 9 would show ticks for every single unit).
- Finally, exclusionRanges allows to define areas where no axis labels will be drawn. In my app, i don’t want to see any labels, so i set a range to exclude the entire visible graph
CPXYAxisSet *axisSet =(CPXYAxisSet *)graph.axisSet; CPXYAxis *x = axisSet.xAxis; x.majorIntervalLength = length:CPDecimalFromFloat(10); x.constantCoordinateValue = length:CPDecimalFromFloat(2); x.minorTicksPerInterval =2; x.borderWidth =0; x.labelExclusionRanges =[NSArray arrayWithObjects:[CPPlotRange plotRangeWithLocation:CPDecimalFromFloat(-100) length:CPDecimalFromFloat(300)], nil];; CPXYAxis *y = axisSet.yAxis; y.majorIntervalLength = length:CPDecimalFromFloat(10); y.minorTicksPerInterval =1; y.constantCoordinateValue = length:CPDecimalFromFloat(2); y.labelExclusionRanges =[NSArray arrayWithObjects:[CPPlotRange plotRangeWithLocation:CPDecimalFromFloat(-100) length:CPDecimalFromFloat(300)], nil]; |
Core Plot supports a variety of chart (or, plot) types, such as bar and pie chart, but for this scenario, we want a simple line graph, which is done via a CPScatterPlot. (A scatter plot actually allows to draw a plot with points scattered all over the place, not just as string y = f(x) values, but we will use it in the more simplistic case).
We’ll create a new CPScatterPlot instance, configure some properties such as its lineWidth and lineColor, and most importantly, set the dataSource to the object that will provide the plot with its data – in this case, self. To round things off, we also tell the plot to fill the area underneath it with a gradient, going from almost-transparent green to nothing. This gives the plot some depth, without hiding the other plots.
CPScatterPlot *dataSourceLinePlot =[[[CPScatterPlot alloc] init] autorelease]; dataSourceLinePlot.identifier =@"AllTests"; dataSourceLinePlot.dataLineStyle.lineWidth =3.f; dataSourceLinePlot.dataLineStyle.lineColor =[CPColor greenColor]; dataSourceLinePlot.dataSource = self; [graph addPlot:dataSourceLinePlot]; // Put an area gradient under the plot above CPColor *areaColor =[CPColor colorWithComponentRed:0.3 green:1.0 blue:0.3 alpha:0.3]; CPGradient *areaGradient =[CPGradient gradientWithBeginningColor:areaColor endingColor:[CPColor clearColor]]; areaGradient.angle =-90.0f; CPFill *areaGradientFill =[CPFill fillWithGradient:areaGradient]; dataSourceLinePlot.areaFill = areaGradientFill; dataSourceLinePlot.areaBaseValue = CPDecimalFromString(@"1.75"); |
This was quite a bit setup, but our graph is now ready plot, and will ask its data source (our cell) for data by sending it messages from the CPPlotDataSource protocol. This protoco will be familiar to any Cocoa developer who worked with, for example, a UITableView or NSTableView, or any other control using a data source.
To the protocol, two methods need to be implemented. The first is numberOfRecordsForPlot:
which will, simply enough, return the number of items (in case of a scatter plot, points, the chart will contain. The second can be one of three methods that return the actual data. We’ll implement numberForPlot:field:recordIndex:
.
The data to display is retrieved via Data Abstract, using a simple DA SQL request on a table that contains all test runs:
NSString*sql =[NSString stringWithFormat:@"SELECT TOP 50 ID, _TotalTestCount, \ _FailedTestCount, FROM TestRuns ORDER BY ID DESC"]; testRuns =[[rda getDataTableWithSQL:sql] retain]; |
int count =[[testRuns rows] count]; int maxTests =[[[testRuns rows] valueForKeyPath:@"@max._TotalTestCount"] intValue]; CPXYPlotSpace *plotSpace =(CPXYPlotSpace *)graph.defaultPlotSpace; plotSpace.xRange =[CPPlotRange plotRangeWithLocation:CPDecimalFromFloat(0) length:CPDecimalFromFloat(count)]; plotSpace.yRange =[CPPlotRange plotRangeWithLocation:CPDecimalFromFloat(0) length:CPDecimalFromFloat(maxTests)]; |
With the data retrieved and the plot space adjusted, we can now implement the two data source methods.
numberOfRecordsForPlot: will simply return the number of rows in our table. * numberForPlot:field:recordIndex* will be called twice per data point, once for the X value (since we’re drawing a “non-scattered” plot, we’ll simply return the indexes) and one for the Y value, which we’ll retrieve from our data table. The different identifiers we assigned to the two plots will serve to distinguish which field to use for the Y value.
-(NSUInteger)numberOfRecordsForPlot:(CPPlot *)plot {return[rows count]; } -(NSNumber*)numberForPlot:(CPPlot *)plot field:(NSUInteger)fieldEnum recordIndex:(NSUInteger)index {switch(fieldEnum){case CPScatterPlotFieldX:{// inverse numbers, so first (latest) test run is on the right.int x =[rows count]-recordIndex; return[NSDecimalNumber numberWithInt:x]; }case CPScatterPlotFieldY:{if([plot.identifier isEqual:@"AllTests"]){float v =[[[rows objectAtIndex:index] valueForKey:@"_TotalTestCount"] floatValue]; return[NSNumber numberWithFloat:v]; }else{float v =[[[rows objectAtIndex:index] valueForKey:@"_FailedTestCount"] floatValue]; return[NSNumber numberWithFloat:v]; }}}returnnil; } |