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:

In the application in question, the chart is being displayed in a cell within a UITableView, so our example starts out by implementing a custom UITableViewCell class that will host the graph. But the same general principles apply to show a chart anywhere else (and, replacing UIView with NSView, for using charts in a desktop app).

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]; //...

Once the CPLayerHostingView is created, we can add a graphs to it, and configure them. There are several graph types supported, but we want a regular line graph with X/Y coordinates, so we’ll choose a CPXYGraph class. We’ll add the graph to the view, and set a padding, to give us some room between the edges of the table cell:

// 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;

The next step is to set up a plot space. A plot space defines the coordinate system for one or more charts in a graph, essentially mapping logical values to the area in the graph. For example, your chart might show values ranging from, say, 0 to 1000. The plot space defines the scale of those values in relation to the graph. It also defines the visual range of values that can be seen on screen.

To match our X/Y graph, we’ll create a CPXYPlotSpace, and set it to a range of 0-100 for both axis:

CPXYPlotSpace plotSpace =(CPXYPlotSpace )graph.defaultPlotSpace; plotSpace.xRange =[CPPlotRange plotRangeWithLocation:CPDecimalFromFloat(0) length:CPDecimalFromFloat(100)]; plotSpace.yRange =[CPPlotRange plotRangeWithLocation:CPDecimalFromFloat(0) length:CPDecimalFromFloat(100)];

Finally, we’ll set up to axis, for the X and Y coordinates. Axis provide labels and tick-marks for the viewer, to give context to the values being shown.

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];

Our graph is now fully set up – except for one important part: we still need to define one or more charts to display actual values. My application needs to show two graphs on top of each other – one showing the total number of tests that ran, and another showing the number of failures.

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");

We repeat the above to add a second plot, except this time we use red for the line color and gradient, and set the identifier to @"FailedTests".

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];

The _TotalTestCount and _FailedTestCount fields are server-calculated based on a relation table and contain the total number of run and failed tests for each test run. We can Key Path operators to get the maximum value, and adjust our plot space accordingly, so that the entire graph fits into the chart:

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)];

This is of course a bit redundant with the values we set earlier, but in real life application, the data access will usually happen in a different place than the initial setup (and data might get changed or refreshed during the course of the application), so i find it important to set up the graph properly to begin with, but then adjust the range to the real data.

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 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; }

And that’s it! You can find more info on Core Plot on their Google code page; i also recommend joining their mailing list for latest news and support.

marc hoffman

Read more posts by this author.