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:

  1. 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’.
  2. 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.)
  3. 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.

  1. Open the window in IB, click on the SM2DGraphView and press backspace to delete it.
  2. Add a Custom View from the IB palette in its place, and change the class of the Custom View to NRTXYChart.
  3. Connect the graphView outlet of the PointsArrayController instance in IB to the NRTXYChart view 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.

Comments

Comment viewing options

Select your preferred way to display the comments and click "Save settings" to activate your changes.

Narrative Attributes

Hi Drew,

I want to use the Narrative framework to plot some data without using an NSArrayController. Is this possible?

Currently I have an application with an NRTXYChart view (Custom View) linked in IB to an IBOutlet NRTXYChart* in another class. When I run the application, a chart appears in the view, however it is to big for the view, and does not have any of the attributes I set using either the setAttributesFromDictionary: method or setAttributeValue: fromKey:.

If I run the following code


[cdfChart setAttributesFromDictionary:[NRTXYChart defaultAttributesDictionary]];
NSLog([cdfChart attributeForKey:@"NRTLeftYAxisTitleCA"]);

I get no output (cdfChart is an NRTXYChart pointer). No matter how I try and set the attributes, I get no output. Can you give me any ideas as to what is going wrong?

Thanks

David Savage

Re: Narrative Attributes

Hi David,

Yes, you can certainly use it without an array controller. That's actually how it is usually used.

After changing the attributes yourself, you may want to call setNeedsLayout: and setNeedsDisplay:, to force an update.

You should use the debugger to make sure that cdfChart is not nil. If it isn't, it could be that the default left y axis title is an empty string. I can't remember to be honest.

Can you get the Example project that comes with Narrative to work? If so, see if you can see where you are going wrong from that.

Kind regards,
Drew

---------------------------
Drew McCormack
http://www.maccoremac.com
http://www.macanics.net
http://www.macresearch.org

Strange Happenings

Hi Drew,

cdfChart was indeed nil. It appears to be working now after making some changes, however the reason why eludes me.

I have two classes I am dealing with, a Main class and a SpreadView class that resembles your vtk view class from the Visulisation tutorial. My Main class has an IBOutlet SpreadView spreadView which is linked to a custom view (SpreadView). This all works fine. Now, I wanted to add a graph that relates to the visulisation and so I added an IBOutlet NRTXYChart cdfChart to the SpreadView class. In IB I can connect a second custom view (NRTXYChart) to this outlet in the original custom view (SpreadView) without any problems. When I run the application though, my cdfChart is nil. To fix this I have to move the IBOutlet NRTXYChart from the SpreadView to the Main class which with my current setup means I have to pass data from the SpreadView to the NRTXYChart through the Main class. Is this because my SpredView class does not have an awakeFromNIb method? How and when are IBOutlets initialized? Is there a better way to do this?

Thanks

David

Re: Strange Happenings

Hi David,
My recommendation is to do it how you are doing it now, that is, don't directly connect the two views, but go through the controller. That's the standard Cocoa MVC way. If you connect and pass data between views directly, it will eventually come back to bite you. Keep your data in your model objects, and access that data from all views via a controller object (ie Main).

Just for the record, outlets are not guaranteed to be connected until awakeFromNib is called on any object. You should be able to connect outlets in views, but you need to make sure you wait until awakeFromNib is invoked.

Drew

---------------------------
Drew McCormack
http://www.maccoremac.com
http://www.macanics.net
http://www.macresearch.org