Cocoa for Scientists (Part IXX): Telling Your Story with Narrative
Author: Drew McCormack
Website: http://www.maccoremac.com
In our last installment, we tool a look at how you can add plotting to a Cocoa application with the SM2DGraphView framework. This time we will take a brief look at another open-source framework, Narrative, and rewrite the Spectra example to use it. Narrative is a framework that I wrote around four years ago to use in the financial software Trade Strategist, but it is also useful for scientific plotting.
Installing Narrative
I will not go into the same detail this time around as I did for SM2DGraphView. Many of the procedures are similar, so I will only discuss the changes necessary at a high level, and hope that by now you are becoming accustomed enough with Xcode and Interface Builder to figure out the details yourself.
You can download the Narrative framework from Sourceforge. After you have unzipped it, it is time to include it in the Spectra app. If you don’t already have Spectra, download the version from last time, and follow this procedure:
- Drag the Narrative.framework folder from the download folder into the Frameworks > Linked Frameworks group of the Spectra Xcode project. When the import sheet appears, check the ‘Copy items into destination group’s folder (if needed)’ item. Also select ‘Create Folder References for any added folders’.
- Open the Targets group in Xcode, and then the Spectra target. Drag the Narrative.framework from Linked Frameworks into the Copy Files build phase at the bottom. (We added this build phase last time; it copies the framework into the application bundle.)
- Drag the Narrative.framework from Linked Frameworks into the Link Binary With Libraries build phase in the Spectra target.
Narrative should now link with Spectra, and get copied into the application bundle when Spectra is built.
Updating the Nib
We now need to update the Nib so that it uses Narrative. First we need to update the PointsArrayController interface, so that it uses Narrative rather than SM2DGraphView. Enter this code into PointsArrayController.h:
#import <Cocoa/Cocoa.h>
#import <Narrative/Narrative.h>
@interface PointsArrayController : NSArrayController {
IBOutlet NRTXYChart *graphView;
}
-(IBAction)refreshGraph:(id)sender;
-(IBAction)printGraph:(id)sender;
@end
To update IB we first need to tell it about the Narrative classes. To do this, open MyDocument.nib in IB. Now open the Narrative.framework > Headers group in Xcode and drag the following header files — in the order given — into the open MyDocument.nib document window in IB: NRTPlotView.h, NRTChart.h, NRTXYChart.h.
You can now add an NRTXYChart to the MyDocument.nib window.
- Open the window in IB, click on the SM2DGraphView and press backspace to delete it.
- Add a Custom View from the IB palette in its place, and change the class of the Custom View to NRTXYChart.
- Connect the
graphViewoutlet of the PointsArrayController instance in IB to theNRTXYChartview you just added.
Adding the Implementation Source Code
Now we need to replace those parts of PointsArrayController that plot the data, so that Narrative gets used, rather than SM2DGraphView. Copy this source code into PointsArrayController.m:
#import "PointsArrayController.h"
@interface PointsArrayController (Private)
-(void)setupLabelsForAxisWithRange:(NRTFloatRange)range
tickCoordinateKey:(NSString *)tickCoordinateKey
axisLabelsKey:(NSString *)axisLabelsKey;
@end
@implementation PointsArrayController
#pragma mark -
#pragma mark Setup
-(void)awakeFromNib
{
// Set chart attributes
NSDictionary *chartProperties = [NSDictionary dictionaryWithObjectsAndKeys:
[NSArray arrayWithObjects:
[NSNumber numberWithFloat:0.0], // red
[NSNumber numberWithFloat:0.0], // green
[NSNumber numberWithFloat:0.0], // blue
[NSNumber numberWithFloat:0.0], nil], // alpha
NRTChartBackgroundRGBACA,
@"Intensity vs Energy", NRTTitleCA,
@"Energy", @"NRTBottomXAxisTitleCA",
@"Intensity", @"NRTLeftYAxisTitleCA",
[NSArray array], NRTTopXAxisLabelsCA,
[NSArray array], NRTTopXAxisTickCoordinatesCA,
[NSArray array], NRTRightYAxisLabelsCA,
[NSArray array], NRTRightYAxisTickCoordinatesCA,
nil];
[graphView setAttributesFromDictionary:chartProperties];
// Add a plot
NRTScatterPlot *plot = [[[NRTScatterPlot alloc] initWithIdentifier:@"linePlot"
andDataSource:self] autorelease];
[plot setAttribute:[NSNumber numberWithFloat:1.0] forKey:NRTLineWidthAttrib];
[plot setAttribute:[NSColor colorWithCalibratedRed:1.0 green:0.2 blue:0.2 alpha:1.0]
forKey:NRTLineColorAttrib];
NRTSymbolClusterGraphic *clusterGraphic = [[[NRTSymbolClusterGraphic alloc] init] autorelease];
[plot setClusterGraphic:clusterGraphic];
[plot setConnectPoints:YES];
[plot setDataSource:self];
[graphView addPlot:plot];
// Update graph
[self refreshGraph:self];
}
#pragma mark -
#pragma mark New Object Creation
-(id)newObject
{
return [[NSMutableDictionary dictionaryWithObjectsAndKeys:
[NSNumber numberWithFloat:0.0], @"energy",
[NSNumber numberWithFloat:0.0], @"intensity",
nil] retain];
}
#pragma mark -
#pragma mark Actions
-(IBAction)refreshGraph:(id)sender
{
if ( [[self arrangedObjects] count] == 0 ) return;
// Determine and set axis ranges
NSNumber *minX = [self valueForKeyPath:@"arrangedObjects.@min.energy"];
NSNumber *maxX = [self valueForKeyPath:@"arrangedObjects.@max.energy"];
NSNumber *minY = [self valueForKeyPath:@"arrangedObjects.@min.intensity"];
NSNumber *maxY = [self valueForKeyPath:@"arrangedObjects.@max.intensity"];
if ( [maxY floatValue] - [minY floatValue] <= 0.0 )
maxY = [NSNumber numberWithFloat:[minY floatValue] + 1.0];
if ( [maxX floatValue] - [minX floatValue] <= 0.0 )
maxX = [NSNumber numberWithFloat:[minX floatValue] + 1.0];
NSDictionary *chartProperties =
[NSDictionary dictionaryWithObjectsAndKeys:
[NSArray arrayWithObjects:minX, maxX, nil], NRTBottomXAxisCoordinateRangeCA,
[NSArray arrayWithObjects:minY, maxY, nil], NRTLeftYAxisCoordinateRangeCA,
nil];
[graphView setAttributesFromDictionary:chartProperties];
// Add ticks
NRTFloatRange xRange = NRTMakeFloatRange( [minX floatValue],
[maxX floatValue] - [minX floatValue] );
NRTFloatRange yRange = NRTMakeFloatRange( [minY floatValue],
[maxY floatValue] - [minY floatValue] );
[self setupLabelsForAxisWithRange:xRange
tickCoordinateKey:NRTBottomXAxisTickCoordinatesCA
axisLabelsKey:NRTBottomXAxisLabelsCA];
[self setupLabelsForAxisWithRange:yRange
tickCoordinateKey:NRTLeftYAxisTickCoordinatesCA
axisLabelsKey:NRTLeftYAxisLabelsCA];
// Require new layout
[graphView setNeedsLayout:YES];
[graphView setNeedsDisplay:YES];
}
-(void)setupLabelsForAxisWithRange:(NRTFloatRange)range
tickCoordinateKey:(NSString *)tickCoordinateKey
axisLabelsKey:(NSString *)axisLabelsKey
{
NRTFloatRangeDiscretizer *discretizer =
[[[NRTFloatRangeDiscretizer alloc] initWithFloatRange:range
desiredNumberOfPoints:5 useOnlyRoundNumbers:YES] autorelease];
NSArray *labelPositions = [discretizer discretePoints];
NSMutableArray *labels = [NSMutableArray array];
NSEnumerator *posEnum = [labelPositions objectEnumerator];
NSNumber *labelPos;
while ( labelPos = [posEnum nextObject] ) {
[labels addObject:[NSString stringWithFormat:@"%.2f", [labelPos floatValue]]];
}
[graphView setAttributesFromDictionary:
[NSDictionary dictionaryWithObjectsAndKeys:
labelPositions, tickCoordinateKey, labels, axisLabelsKey, nil]];
}
-(IBAction)printGraph:(id)sender
{
[graphView print:self];
}
#pragma mark -
#pragma mark Plot Data Source Methods
-(unsigned)numberOfDataClustersForPlot:(NRTPlot *)plot
{
return [[self arrangedObjects] count];
}
-(NSDictionary *)clusterCoordinatesForPlot:(id)plot andDataClusterIndex:(unsigned)entryIndex
{
id point = [[self arrangedObjects] objectAtIndex:entryIndex];
return [NSDictionary dictionaryWithObjectsAndKeys:
[point valueForKey:@"energy"], NRTXClusterIdentifier,
[point valueForKey:@"intensity"], NRTYClusterIdentifier, nil];
}
@end
Setting Chart Properties
That’s quite a chunk of code. Let’s review what it is doing: In awakeFromNib, various properties of the chart are set. Narrative doesn’t have any Nib support, so you have to do all the configuration programmatically. That said, you have a lot of control over nearly all aspects of a Narrative graph, and can configure it all via dictionaries and property lists. In this case, I have used a hard-coded dictionary, but you can also create an external property list, and read that in at run time. This approach is demonstrated in the Example project that comes with the Narrative download.
Setting the properties typically involves creating a dictionary with those properties that you are interested in changing. Here is the part of awakeFromNib that does that:
// Set chart attributes
NSDictionary *chartProperties = [NSDictionary dictionaryWithObjectsAndKeys:
[NSArray arrayWithObjects:
[NSNumber numberWithFloat:0.0], // red
[NSNumber numberWithFloat:0.0], // green
[NSNumber numberWithFloat:0.0], // blue
[NSNumber numberWithFloat:0.0], nil], // alpha
NRTChartBackgroundRGBACA,
@"Intensity vs Energy", NRTTitleCA,
@"Energy", @"NRTBottomXAxisTitleCA",
@"Intensity", @"NRTLeftYAxisTitleCA",
[NSArray array], NRTTopXAxisLabelsCA,
[NSArray array], NRTTopXAxisTickCoordinatesCA,
[NSArray array], NRTRightYAxisLabelsCA,
[NSArray array], NRTRightYAxisTickCoordinatesCA,
nil];
[graphView setAttributesFromDictionary:chartProperties];
In this case, we are setting the background color of the whole chart to be clear, by including an array holding RGBA values for the key NRTChartBackgroundRGBACA. We also set the title, via NRTTitleCA; the axis titles, via NRTBottomXAxisTitleCA and NRTLeftYAxisTitleCA; and we ensure the top and right axes have no labels by setting empty arrays for the corresponding keys. (If you are wondering why NRTBottomXAxisTitleCA and NRTLeftYAxisTitleCA are given as literal NSStrings, it’s simply because there is a bug in Narrative, and variables for those properties have accidentally been excluded.) The method setAttributesFromDictionary: is then used to set the properties of the NRTXYChart.
Adding Plots
Narrative uses a data source model to supply data to the chart, just as SM2DGraphView, but — unlike SM2DGraphView — Narrative has a separate plot class that represents a single plot on the chart: NRTPlot. To supply each plot with data, you set the dataSource of the plot to an object that can supply the data. Here is how we add a plot in awakeFromNib:
// Add a plot
NRTScatterPlot *plot = [[[NRTScatterPlot alloc] initWithIdentifier:@"linePlot"
andDataSource:self] autorelease];
[plot setAttribute:[NSNumber numberWithFloat:1.0] forKey:NRTLineWidthAttrib];
[plot setAttribute:[NSColor colorWithCalibratedRed:1.0 green:0.2 blue:0.2 alpha:1.0]
forKey:NRTLineColorAttrib];
NRTSymbolClusterGraphic *clusterGraphic = [[[NRTSymbolClusterGraphic alloc] init] autorelease];
[plot setClusterGraphic:clusterGraphic];
[plot setConnectPoints:YES];
[plot setDataSource:self];
[graphView addPlot:plot];
This adds a single scatter plot, which takes pairs of x, y coordinates. The same method is used to set plot properties as was used for the chart itself; here we see that the plot line width is set, and the line color. You can also define a graphic for each point on the line (ie, NRTSymbolClusterGraphic), but in this case, we just set an empty graphic, which will result in only the line itself being drawn. The second last line sets the plots data source to be the controller, and the very last line adds the plot to the chart.
To retrieve its data, the plot will callback to our controller, to these methods:
-(unsigned)numberOfDataClustersForPlot:(NRTPlot *)plot
{
return [[self arrangedObjects] count];
}
-(NSDictionary *)clusterCoordinatesForPlot:(id)plot
andDataClusterIndex:(unsigned)entryIndex
{
id point = [[self arrangedObjects] objectAtIndex:entryIndex];
return [NSDictionary dictionaryWithObjectsAndKeys:
[point valueForKey:@"energy"], NRTXClusterIdentifier,
[point valueForKey:@"intensity"], NRTYClusterIdentifier, nil];
}
The numberOfDataClustersForPlot: method just returns the number of points, or clusters, on the plot. The x,y points themselves are supplied by clusterCoordinatesForPlot:andDataClusterIndex:. This method must return a dictionary with the key-value pairs required by the plot in question. For a scatter plot, it just has to supply an X and a Y value. The keys for the dictionary can be found in the Narrative framework in the header NRTDataClusterIdentifiers.h.
Dynamically Altering Chart Properties
The rest of the controller class is concerned with updating the chart when the refresh button is pressed. This involves dynamically varying chart properties, such as the axis labels and ranges. The refresh: action method starts like this:
-(IBAction)refreshGraph:(id)sender
{
if ( [[self arrangedObjects] count] == 0 ) return;
// Determine and set axis ranges
NSNumber *minX = [self valueForKeyPath:@"arrangedObjects.@min.energy"];
NSNumber *maxX = [self valueForKeyPath:@"arrangedObjects.@max.energy"];
NSNumber *minY = [self valueForKeyPath:@"arrangedObjects.@min.intensity"];
NSNumber *maxY = [self valueForKeyPath:@"arrangedObjects.@max.intensity"];
if ( [maxY floatValue] - [minY floatValue] <= 0.0 )
maxY = [NSNumber numberWithFloat:[minY floatValue] + 1.0];
if ( [maxX floatValue] - [minX floatValue] <= 0.0 )
maxX = [NSNumber numberWithFloat:[minX floatValue] + 1.0];
NSDictionary *chartProperties =
[NSDictionary dictionaryWithObjectsAndKeys:
[NSArray arrayWithObjects:minX, maxX, nil], NRTBottomXAxisCoordinateRangeCA,
[NSArray arrayWithObjects:minY, maxY, nil], NRTLeftYAxisCoordinateRangeCA,
nil];
[graphView setAttributesFromDictionary:chartProperties];
This code is simply determining the maximum and minimum values of x and y, so that the axis ranges can be set appropriately. Once the range has been set, the tick marks need to be positioned on the axis:
// Add ticks
NRTFloatRange xRange = NRTMakeFloatRange( [minX floatValue],
[maxX floatValue] - [minX floatValue] );
NRTFloatRange yRange = NRTMakeFloatRange( [minY floatValue],
[maxY floatValue] - [minY floatValue] );
[self setupLabelsForAxisWithRange:xRange
tickCoordinateKey:NRTBottomXAxisTickCoordinatesCA
axisLabelsKey:NRTBottomXAxisLabelsCA];
[self setupLabelsForAxisWithRange:yRange
tickCoordinateKey:NRTLeftYAxisTickCoordinatesCA
axisLabelsKey:NRTLeftYAxisLabelsCA];
This code calls the private method setupLabelsForAxisWithRange:tickCoordinateKey:axisLabelsKey:, which uses the Narrative class NRTFloatRangeDiscretizer to determine a neat distribution of ticks, and then sets the tick attributes of the chart view:
-(void)setupLabelsForAxisWithRange:(NRTFloatRange)range
tickCoordinateKey:(NSString *)tickCoordinateKey
axisLabelsKey:(NSString *)axisLabelsKey
{
NRTFloatRangeDiscretizer *discretizer =
[[[NRTFloatRangeDiscretizer alloc] initWithFloatRange:range
desiredNumberOfPoints:5 useOnlyRoundNumbers:YES] autorelease];
NSArray *labelPositions = [discretizer discretePoints];
NSMutableArray *labels = [NSMutableArray array];
NSEnumerator *posEnum = [labelPositions objectEnumerator];
NSNumber *labelPos;
while ( labelPos = [posEnum nextObject] ) {
[labels addObject:[NSString stringWithFormat:@"%.2f", [labelPos floatValue]]];
}
[graphView setAttributesFromDictionary:
[NSDictionary dictionaryWithObjectsAndKeys:
labelPositions, tickCoordinateKey, labels, axisLabelsKey, nil]];
}
This probably should all happen behind the scenes, but what you lose in simplicity, you gain in flexibility. The NRTXYChart class doesn’t impose any restrictions on what labels you use, or where you position them. If you want evenly distributed, round number labels, you can easily produce them yourself using NRTFloatRangeDiscretizer, and then set the label format as desired. (In this case, a format with 2 decimal places has been used.)
Concluding
You can download the complete source of the Narrative version of Spectra here.
In conclusion, Narrative and SM2DGraphView are both reasonable open-source plotting solutions for Cocoa plotting, with rather different philosophies. SM2DGraphView is somewhat simpler to use, and is better documented; Narrative is designed to be easily customizable, and the barrier to entry is somewhat higher.
There are a couple of commercial plotting frameworks worthy of mention. One that has been around since the NeXT days is VVI. A newer entry into the field is the DataGraph framework by David Adalsteinsson, which is used in the application of the same name. I haven’t had a chance to try out either of these frameworks, but I am hoping to convince David to write something about DataGraph for MacResearch at some point in the future.


