screenshot_1

Back to Blog

Data streaming crosshairs and custom tooltips in ShinobiCharts

Posted on 4 Dec 2013 Written by Alison Clarke

Data streaming is a relatively new feature of ShinobiCharts (introduced in version 2.3) and not only opens up a whole new world of possibilities, but also introduces more complexity to your apps. This blog post uses a movement tracker app to demonstrate how you can add a custom crosshair tooltip to your ShinobiChart, and how to make sure it behaves nicely as new data is added to the chart.

If you haven’t built an app using ShinobiCharts before, then it would probably be a good idea to work through the quick start guide before reading on, because this tutorial doesn’t go into detail about the basics of ShinobiCharts.

About the app

The movement tracker app graphs the user’s current speed and distance, with a tooltip which shows a map displaying their location at the selected point, and the path they’ve taken. The project can be found on GitHub, or downloaded as a zip. In order to build the project you’ll need a copy of ShinobiCharts for iOS. If you don’t have it yet, you can download a free trial from the ShinobiCharts website. If you’ve used the installer to install ShinobiCharts, everything should just work. If you haven’t, then once you’ve downloaded and unzipped ShinobiCharts, open up the project in Xcode, and drag ShinobiCharts.embeddedframework from the finder into Xcode’s ‘frameworks’ group, and Xcode will sort out all the header and linker paths for you.

If you’re using the trial version you’ll also need to add your license key. To do so, open up ViewController.m and add the following line after the chart is initialised:

chart.licenseKey=@"your license key";

Hopefully you should now be able to build the project. Before firing it up in the simulator, set up a moving location (Debug -> Location -> City Bicycle Ride works nicely with the default settings). Then run the app and watch your graph appear as the location changes; tap on a line to see a tooltip showing the location on a map.

Screenshot

Here’s a quick guide to what’s what in the source code, before we dive into the specifics:

  • ViewController owns the CLLocationManager which provides us with our data, and sets up the chart and its datasource.
  • MovementTrackerDatum is a simple data object representing a point on the chart. It holds a CLLocation object, and the speed and total distance traveled at a single point in time.
  • MovementTrackerDataSource adopts the SChartDataSource protocol. It creates and holds a list of MovementTrackerDatum objects.
  • MapTooltip is a custom implementation of SChartTooltip, which replaces the usual label with a map showing the location.
  • MapCrosshair is a custom implementation of SChartCrosshair, which makes sure the crosshair remains visible and moves to the right place when the chart is redrawn.

Location Services

The data for the app comes from the location services provided by the CoreLocation framework. If you haven’t used CoreLocation before there’s a guide here. The first thing the application does is to set up the location manager inside initWithCoder: in ViewController.m:

// Set up the location manager
_locationManager = [[CLLocationManager alloc] init];
_locationManager.delegate = self;
_locationManager.desiredAccuracy = kCLLocationAccuracyBestForNavigation;
_locationManager.distanceFilter = 5;
[_locationManager startUpdatingLocation];

We set up the view controller as a CLLocationManagerDelegate so that we can respond to changes in the user’s location. The view controller implements the locationManager:didUpdateLocations: method, to pass the user’s new location to both the datasource (to update the chart) and the map tooltip (to draw the path on the map). We’ll look at the details of that method later on.

The Data Source

MovementTrackerDataSource is an implementation of SChartDatasource. It stores the movement data in an array of MovementTrackerDatum objects, _locationData. The implementations of the 4 required methods of the SChartDataSource protocol are fairly straightforward:  

#pragma mark - SChartDatasource implementation methods

- (int)numberOfSeriesInSChart:(ShinobiChart*)chart
{
    return 2;
}

-(SChartSeries *)sChart:(ShinobiChart *)chart seriesAtIndex:(int)index {
    SChartLineSeries* lineSeries = [[SChartLineSeries alloc] init];
    if (index == 0) {
        lineSeries.title = @"Speed";
    } else {
        lineSeries.title = @"Distance";
    }
    lineSeries.crosshairEnabled = YES;
    lineSeries.style.pointStyle.showPoints = YES;
    return lineSeries;
}

-(int)sChart:(ShinobiChart *)chart numberOfDataPointsForSeriesAtIndex:(int)seriesIndex {
    return _locationData.count;
}

- (id<SChartData>)sChart:(ShinobiChart *)chart dataPointAtIndex:(int)dataIndex forSeriesAtIndex:(int)seriesIndex {
    
    MovementTrackerDatum *datum = [_locationData objectAtIndex:dataIndex];
    
    // Create a new datapoint
    SChartDataPoint* pt = [SChartDataPoint new];
    pt.xValue = datum.date;
    
    if (seriesIndex==0) {
        // First series is speed
        pt.yValue = @(datum.speed);
    } else {
        // Second series is distance
        pt.yValue = @(datum.totalDistance);
    }
    
    return pt;
}

Note that we’ve enabled crosshairs for both data series. The sChart:dataPointAtIndex:forSeries method just grabs the relevant MovementTrackerDatum object from _locationData and creates an SChartDataPoint object from its timestamp and its speed or distance.

The data source also implements the optional method sChart:yAxisforSeriesAtIndex: because each series has its own y-axis:

- (SChartAxis *)sChart:(ShinobiChart *)chart yAxisForSeriesAtIndex:(int)index {
    NSArray* axes = chart.allYAxes;
    return axes[index];
}

Now we’ve implemented everything required by the SChartDataSource protocol, but how does the data get added to the data source? MovementTrackerDataSource provides an addLocation: method:

// Adds the location to our data
- (void)addLocation:(CLLocation*)location {
    // Create a new MovementTrackerDatum object based on the given location
    MovementTrackerDatum *datum = [[MovementTrackerDatum alloc] init];
    datum.date = location.timestamp;
    datum.location = location;
    if ([_locationData count] > 0)
    {
        // Convert distance (in meters) into miles and add to total
        _totalDistance += [location distanceFromLocation:[self getLastLocation]] * 0.000621371192;
    }
    datum.totalDistance = _totalDistance;
    // Convert speed (in m/s) into mph
    datum.speed = location.speed * 2.23693629;
    
    // Add to the series
    [_locationData addObject:datum];
}

This method accepts the current locations and creates a new MovementTrackerDatum object, converting the metric units received into imperial units, and calculating the total distance travelled. getLastLocation is a trivial method returning the location from the last item in _locationData.

Setting up the chart

The chart setup is done in the setupChart method inside ViewController.m:

- (void)setupChart {
    // Initialize chart and do basic setup
    _chart = [[ShinobiChart alloc] initWithFrame: _placeholderView.bounds];
    _chart.datasource = _datasource;
    _chart.delegate = self;
    _chart.autoresizingMask = UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight;
    _chart.legend.hidden = NO;
    _chart.legend.position = SChartLegendPositionBottomMiddle;
    _chart.title = @"Movement Tracker";

    // Turn off clipsToBounds so that our tooltip can go outside of the chart area
    [_chart setClipsToBounds:NO];
    
    // Add x-axis
    SChartDateTimeAxis *dateAxis = [[SChartDateTimeAxis alloc] init];
    dateAxis.title = @"Time";
    _chart.xAxis = dateAxis;
    
    // Add first y-axis (speed)
    SChartNumberAxis *speedAxis = [[SChartNumberAxis alloc] init];
    speedAxis.title = @"Speed (mph)";
    _chart.yAxis = speedAxis;
    
    // Add second y-axis (distance)
    SChartNumberAxis *distanceAxis = [[SChartNumberAxis alloc] init];
    distanceAxis.title = @"Distance (miles)";
    distanceAxis.axisPosition = SChartAxisPositionReverse;
    distanceAxis.axisLabelsAreFixed = YES;
    [_chart addYAxis: distanceAxis];
    
    ...
    
    // Add the chart to our view

    [_placeholderView addSubview: _chart];
    
    _chartSetup = YES;
}

Most of this is again fairly standard chart setup. What you may not have seen before is multiple y-axes: we have a separate y axis for speed and distance, and place the second (distance) y-axis on the right hand side of the chart. We’ll use the variable _chartSetup to check whether the chart setup is complete later on: we need to know that it’s loaded rather than just that it’s not null, to avoid race conditions.

Normally we’d call a setupChart method inside viewDidLoad. However, in this case, we don’t have any datapoints when the application starts. This would be fine if we had fixed the ranges on the axes, but we want the ranges to be automatic, and to keep updating as the data is added, so if we try to set up the chart without data points or data ranges, it will get a bit confused. We get round this by calling setupChart after we’ve received the first datapoint from the location manager, inside locationManager:didUpdateLocations:

// Delegate method from the CLLocationManagerDelegate protocol
- (void)locationManager:(CLLocationManager *)manager didUpdateLocations:(NSArray *)locations {
    CLLocation* location = [locations lastObject];
    CLLocation* lastLocation = [_datasource getLastLocation];
    
    // Add the location to our datasource
    [_datasource addLocation:location];
    
    if (!_chartSetup) {
        // Set up the chart now we've got a data point
        [self setupChart];
    } else {
        
        if ([[_chart series] count]) { // Prevents an early location update from crashing the chart if it hasn't yet loaded!
            [_chart appendNumberOfDataPoints:1 toEndOfSeriesAtIndex:0];
            [_chart appendNumberOfDataPoints:1 toEndOfSeriesAtIndex:1];
        }
        
        [_chart redrawChart];
        
        ...
    }
}

The method first calls addLocation: method on our datasource. It then checks whether the chart has been setup, and sets it up if not. If the chart has been set up, we call appendNumberOfDataPoints:toEndOfSeriesAtIndex: for both series, and redraw it.

If you’ve got to this point (and added in all the necessary boilerplate code – see GitHub) then you should have a streaming chart:

Streaming Screenshot

Crosshairs and custom tooltips

We’ve now seen how to set up a live datasource to give us a streaming chart; now we’ll add a custom tooltip to make it a bit more interesting. First, let’s clarify what we mean by crosshair and tooltip, as the terms are often confused:

  • The crosshair consists of the vertical and horizontal lines from the selected point on the chart to the x and y axes.
  • The tooltip belongs to the crosshair and is a view which displays information about the selected point.

The custom tooltip

We’ll look at our custom tooltip first. MapTooltip is a subclass of SChartCrosshairTooltip, and implements the MKMapViewDelegate protocol. It is initialized with a MovementTrackerDataSource and a CLLocationManager, which it uses to retrieve its data later on. SChartCrossHairTooltip is a subclass of UIView, so our tooltip’s constructor simply sets up the view to be in a 200×200 frame with a black border, then calls the setupMap method:

-(void)setupMap {
    // Create and set up an MKMapView, using ourselves as its delegate
    _mapView = [[MKMapView alloc] initWithFrame:self.frame];
    _mapView.showsUserLocation = YES;
    _mapView.delegate = self;
    _mapView.scrollEnabled = NO;
    _mapView.zoomEnabled = NO;
    
    // Create a region, which we'll reuse to keep the zoom constant
    _region = MKCoordinateRegionMakeWithDistance(_locationManager.location.coordinate, 250, 250);
    [_mapView setRegion:_region animated:YES];
    
    // Prevent the map from scrolling with the user - we'll control its center ourselves
    [_mapView setUserTrackingMode:MKUserTrackingModeNone animated:NO];
    
    // Remove the original tooltip label and replace it with our map
    [self.label removeFromSuperview];
    [self addSubview: _mapView];
}

This method creates an MKMapView, centered on the current location, and adds it to the current view in place of the standard tooltip label. The map view sets itself as the delegate, so we can draw an overlay on top of the map to plot our path.

The tooltip provides a method addLocations: which gets called from within locationManager:didUpdateLocations: in the view controller. The addLocations: method takes an array of locations and creates an MKPolyline between the points, then adds it as an overlay to the map view:

-(void)addLocations:(NSArray *)locations {
    // Add the given locations to our annotation line
    int numPoints = [locations count];
    if (numPoints > 1)
    {
        CLLocationCoordinate2D* coords = malloc(numPoints * sizeof(CLLocationCoordinate2D));
        for (int i = 0; i < numPoints; i++)
        {
            CLLocation* current = [locations objectAtIndex:(i)];
            coords[i] = current.coordinate;
        }
    
        _polyline = [MKPolyline polylineWithCoordinates:coords count:numPoints];
        free(coords);
    
        [_mapView addOverlay:_polyline];
        [_mapView setNeedsDisplay];
    }
}

The polyline is added to the map by the mapView:rendererForOverlay: implementation from the MKMapViewDelegate protocol:

#pragma mark - MKMapViewDelegate methods

- (MKOverlayRenderer *)mapView:(MKMapView *)mapView rendererForOverlay:(id<MKOverlay>)overlay
{
    MKPolylineRenderer *renderer = [[MKPolylineRenderer alloc] initWithPolyline:overlay];
    renderer.fillColor = [UIColor redColor];
    renderer.strokeColor = [UIColor redColor];
    renderer.lineWidth = 3;
    return renderer;
}

This just creates an MKPolylineRenderer from the current polyline, with a thick red line, and returns it – see the “Rendering a polyline” section of Sam’s MapKit tutorial for more details.

Now we’ve got a map view which will plot our course, so we need to turn it into a nice tooltip. To do this we need to override two SChartTooltip methods. The first is setPosition:onCanvas:

- (void)setPosition:(SChartPoint)pos onCanvas:(SChartCanvas*)canvas {
    [self layoutContents];
    
    // Position the tooltip on the canvas, with the given position as its center
    CGRect tempFrame = _mapView.frame;
    tempFrame.origin.x = pos.x - tempFrame.size.width/2.f;
    tempFrame.origin.y = pos.y - tempFrame.size.height/2.f;
    self.frame = tempFrame;
}

This method is called when a crosshair is moved to the given SChartPoint, to allow the tooltip to be positioned on the chart. Our implementation simply centers the frame around the given point.

The second overridden method is setDataPoint:fromSeries:fromChart:, which again is called when a crosshair has a new position, to allow the tooltip’s label to be updated with the given data:

- (void)setDataPoint:(id<SChartData>)dataPoint fromSeries:(SChartSeries *)series fromChart:(ShinobiChart *)chart {
    // Find relevant data point from data source;
    CLLocation* location = [_datasource getMovementTrackerDatumAtIndex:dataPoint.sChartDataPointIndex].location;
    
    if (location != NULL) {
        // Remove the old annotation
        [_mapView removeAnnotation:_annotation];
        
        // Place a single pin in the map
        if (_annotation == NULL) {
            _annotation = [[MKPointAnnotation alloc] init];
        }
        [_annotation setCoordinate:location.coordinate];
        
        NSString *unit;
        SChartAxis *yAxis;
        if ([series.title isEqualToString:@"Distance"]) {
            unit = @"miles";
            yAxis = chart.secondaryYAxes[0];
        } else {
            unit = @"mph";
            yAxis = chart.yAxis;
        }
        [_annotation setTitle:[NSString stringWithFormat:@"%@: %@ %@", [series title], [yAxis stringForId: [dataPoint sChartYValue]], unit]];
        [_annotation setSubtitle:[NSString stringWithFormat:@"Time: %@", [chart.xAxis stringForId: [dataPoint sChartXValue]]]];
        [_mapView addAnnotation:_annotation];
        [_mapView selectAnnotation:_annotation animated:NO];
        
        // Center map on that location
        _region.center = location.coordinate;
        [_mapView setRegion:_region animated:NO];
    }
}

This method uses our MovementTrackerDataSource to get the CLLocation object from the given dataPoint. It then draws an MKPointAnnotation at that location, and sets its title and subtitle to display the distance and time. Finally it centres the map on the selected location. So we end up with a tooltip looking something like this:

 Tooltip

The custom crosshair

The built-in crosshair can be set to use our custom tooltip, and it will work fine…until the location is updated, when the crosshair and tooltip will disappear. This is because the chart is redrawn at that point, and the default behaviour is to hide the crosshair when that happens: that’s the sensible thing to in the generic case, because the data in the newly drawn chart may bear no resemblance to the old data. In our case, however, we know that the data point which was selected when the chart was redrawn will still be there (even though it will have moved), so we want to keep the crosshair and tooltip visible, but move them to their new position on the graph. We do this in MapCrosshair, our custom implementation of SChartCrosshair.

The overridden SChartCrosshair methods in MapCrosshair simply keep track of the current state of play of the crosshair, then call the parent method:

-(void)showCrosshair
{
    // Keep track of current status before passing to parent
    _isShown = YES;
    [super showCrosshair];
}

-(BOOL)removeCrosshair
{
    // Keep track of current status before passing to parent
    _isShown = NO;
    return [super removeCrosshair];
}

- (void)moveToPosition:(SChartPoint)coords
   andDisplayDataPoint:(SChartPoint)dataPoint
            fromSeries:(SChartCartesianSeries *)series
    andSeriesDataPoint:(id<SChartData>)dataSeriesPoint
{
    // If the crosshair is in range, keep track of current data before passing to the parent
    if (_inRange) {
        _lastCoords = coords;
        _lastDataPoint = dataPoint;
        _lastSeries = series;
        _lastDataSeriesPoint = dataSeriesPoint;
    }
    [super moveToPosition:coords andDisplayDataPoint:dataPoint fromSeries:series andSeriesDataPoint:dataSeriesPoint];
}

-(void)crosshairMovedOutOfRange {
    // Keep track of current status before passing to parent
    _inRange = NO;
    [super crosshairMovedOutOfRange];
}

-(void)crosshairMovedInsideRange {
    // Keep track of current status before passing to parent
    _inRange = YES;
    [super crosshairMovedInsideRange];
}

The last method in MapCrosshair needs to be called after the chart is redrawn, and it works out whether the crosshair should be displayed and its new position:

-(void)updateCrosshair
{
    if (_isShown) {
        // Update the crosshair's position, based on the previous data
        [self crosshairMovedInsideRange];
        [super showCrosshair];
   
        // Find the relevant y-axis for the series
        SChartAxis *yAxis = self.chart.yAxis;
        if ([_lastSeries.title isEqualToString:@"Distance"]) {
            yAxis = self.chart.secondaryYAxes[0];
        }
        
        // Calculate the new pixel positions
        double xCoord = self.chart.canvas.glView.frame.origin.x + [self.chart.xAxis pixelValueForDataValue: @(_lastDataPoint.x)];
        double yCoord = self.chart.canvas.glView.frame.origin.y + [yAxis pixelValueForDataValue: @(_lastDataPoint.y)];
        
        SChartPoint const mappedPosition = {
            xCoord, yCoord
        };
        
        // Call moveToPosition with our new position and the previous data
        [super moveToPosition:mappedPosition andDisplayDataPoint:_lastDataPoint fromSeries:_lastSeries andSeriesDataPoint:_lastDataSeriesPoint];
    }
}

The method makes use of the pixelValueForDataValue: method of an axis, which converts a data value to the distance in pixels from the origin, to calculate the new position of the last selected data point. It then calls the parent method to move the crosshair to the new position.

Putting it together

To use our custom crosshair and tooltip classes, we set them up inside the setupChart method in ViewController:

    // Set up custom tooltip and crosshair style
    _mapTooltip = [[MapTooltip alloc] initWithDatasource:_datasource locationManager:_locationManager];
    _mapCrosshair = [[MapCrosshair alloc] initWithChart:_chart];
    _mapCrosshair.interpolatePoints = YES;
    _chart.crosshair = _mapCrosshair;
    _chart.crosshair.tooltip = _mapTooltip;
    _chart.crosshair.style.lineWidth = [NSNumber numberWithInt:2];
    _chart.crosshair.style.lineColor = [UIColor blackColor];

We also implement the SChartDelegate methods sChartRenderFinished: and sChart:crosshairMovedToXValue:andYValue:. (Note that we set the view controller as the chart’s delegate in setupChart.)

#pragma mark - SChartDelegate methods

-(void)sChartRenderFinished:(ShinobiChart *)chart {
    
    [self reorderSubviews: chart];
    
    [_mapCrosshair updateCrosshair];
}

-(void)sChart:(ShinobiChart *)chart crosshairMovedToXValue:(id)x andYValue:(id)y {
    
    [self reorderSubviews: chart];
}

// Reorder the subviews to ensure that the tooltip is the top view in the chart
-(void)reorderSubviews:(ShinobiChart *)chart {
    
    [chart bringSubviewToFront: chart.canvas];
    [chart.canvas.overlay bringSubviewToFront: chart.crosshair];
}

The reorderSubviews method ensures that the tooltip is drawn on top of the axes when it reaches the edges of the chart. We need to do this whenever the chart or crosshair has been redrawn. We also call our crosshair’s updateCrosshair method when the chart is redrawn.

And there we have it…an app which streams data to the chart, shows a custom tooltip, and persists the crosshair as new data is added. You can see the full project on GitHub, or download it as a zip. Any questions? Give us a shout.

Screenshot

Back to Blog